feat: add tracking link schemas for creation, update, and querying#124
Conversation
- Introduced `createTrackingLinkSchema` and `updateTrackingLinkSchema` for validating tracking link data. - Added `trackingLinkIdSchema` for route parameter validation. - Created `trackingLinkQuerySchema` for listing tracking links with pagination and filtering options. - Implemented `sourceStatsQuerySchema` for querying source tracking statistics. - Added `applicationSourceSchema` for capturing source attribution from URL query parameters.
|
🚅 Deployed to the reqcore-pr-124 environment in applirank
|
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
📝 WalkthroughWalkthroughAdds a full Source Tracking feature: DB migration and Drizzle schema, server APIs for tracking links, stats, public redirects and attribution, frontend pages/composables/UI for managing links and analytics, job-listing query propagation, tests, and permission updates. Changes
Sequence Diagram(s)sequenceDiagram
participant Visitor as Visitor (browser)
participant PublicTrack as Server: GET /api/public/track/:code
participant DB as Database
participant Redirect as Browser (redirect target)
Visitor->>PublicTrack: GET /api/public/track/:code
PublicTrack->>DB: SELECT tracking_link WHERE code = :code
alt link found and active
DB-->>PublicTrack: tracking_link + job.slug
PublicTrack->>DB: UPDATE tracking_link SET click_count = click_count + 1 (async)
PublicTrack->>Redirect: 302 -> /jobs/:slug/apply?ref=:code
Redirect-->>Visitor: loads apply page with ref param
else not found
PublicTrack-->>Visitor: 404 Not Found
end
sequenceDiagram
participant Candidate as Candidate (browser)
participant ApplyClient as Client: Apply page JS
participant API as Server: POST /api/public/jobs/:slug/apply
participant DB as Database
participant Attr as Server: attribution logic
Candidate->>ApplyClient: Submit application (includes ref, utm_*)
ApplyClient->>API: POST body or FormData with attribution fields
API->>DB: INSERT application
API->>Attr: attempt attribution (resolve tracking link by ref, extract referrer/domain)
alt tracking link found
Attr->>DB: UPDATE tracking_link.application_count += 1 (best-effort)
Attr->>DB: INSERT application_source with tracking_link_id + utm fields + channel
else fallback
Attr->>DB: determine channel from UTM/referrer and INSERT application_source
end
DB-->>API: OK
API-->>ApplyClient: 200/201 application confirmation
Estimated code review effort🎯 5 (Critical) | ⏱️ ~120 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 1 | ❌ 2❌ Failed checks (1 warning, 1 inconclusive)
✅ Passed checks (1 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 10
🧹 Nitpick comments (8)
server/utils/schemas/trackingLink.ts (1)
69-76: Consider extracting shared UTM field definitions to reduce duplication.The UTM fields (
utmSource,utmMedium,utmCampaign,utmTerm,utmContent) are duplicated acrosscreateTrackingLinkSchema,updateTrackingLinkSchema, andapplicationSourceSchema. Extracting a shared partial schema would improve maintainability.♻️ Proposed refactor to reduce duplication
+/** Shared UTM field definitions */ +const utmFieldsSchema = z.object({ + utmSource: z.string().max(200).optional(), + utmMedium: z.string().max(200).optional(), + utmCampaign: z.string().max(200).optional(), + utmTerm: z.string().max(200).optional(), + utmContent: z.string().max(200).optional(), +}) + /** Schema for creating a tracking link */ export const createTrackingLinkSchema = z.object({ jobId: z.string().min(1).optional(), channel: sourceChannelSchema.default('custom'), name: z.string().min(1, 'Name is required').max(200), - utmSource: z.string().max(200).optional(), - utmMedium: z.string().max(200).optional(), - utmCampaign: z.string().max(200).optional(), - utmTerm: z.string().max(200).optional(), - utmContent: z.string().max(200).optional(), -}) +}).merge(utmFieldsSchema)🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@server/utils/schemas/trackingLink.ts` around lines 69 - 76, The UTM fields are duplicated across createTrackingLinkSchema, updateTrackingLinkSchema, and applicationSourceSchema; create a shared Zod partial (e.g., utmFieldsSchema = z.object({ utmSource: z.string().max(200).optional(), utmMedium: z.string().max(200).optional(), utmCampaign: z.string().max(200).optional(), utmTerm: z.string().max(200).optional(), utmContent: z.string().max(200).optional() })) and replace the repeated field definitions by merging or extending that partial into applicationSourceSchema and into the definitions used by createTrackingLinkSchema and updateTrackingLinkSchema (use .merge() or spread with .extend() as appropriate) so all three schemas reference the single utmFieldsSchema.server/api/tracking-links/index.get.ts (1)
1-1: Remove unused import.The
sqlimport fromdrizzle-ormis not used in this file.🧹 Proposed fix
-import { eq, and, desc, sql } from 'drizzle-orm' +import { eq, and, desc } from 'drizzle-orm'🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@server/api/tracking-links/index.get.ts` at line 1, The import list in server/api/tracking-links/index.get.ts includes an unused symbol `sql` from 'drizzle-orm'; remove `sql` from the import statement (leave `eq`, `and`, `desc`) to clean up unused imports and avoid lint warnings, updating the import that currently reads like "import { eq, and, desc, sql } from 'drizzle-orm'".server/api/public/jobs/[slug]/apply.post.ts (1)
740-743: Minor: Redundant exact-match check in suffix matching loop.Line 740 already handles exact matches (
if (mapping[d])), so thed === keycheck in line 742 will never be true.🧹 Proposed simplification
// Check for exact match first, then suffix match for subdomains if (mapping[d]) return mapping[d]! for (const [key, channel] of Object.entries(mapping)) { - if (d.endsWith(`.${key}`) || d === key) return channel + if (d.endsWith(`.${key}`)) return channel } return null🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@server/api/public/jobs/`[slug]/apply.post.ts around lines 740 - 743, The loop that finds a channel for a domain redundantly checks exact equality (d === key) even though the exact match is already handled by the earlier if (mapping[d]) return mapping[d]!, so update the loop in the domain-to-channel lookup to only test suffix matches (i.e., remove the d === key branch) by changing the for (const [key, channel] of Object.entries(mapping)) { if (d.endsWith(`.${key}`)) return channel } so exact matches remain handled by mapping[d] and the loop only handles subdomain suffixes.server/api/source-tracking/stats.get.ts (2)
37-159: Consider adding a composite index for performance.The 8 concurrent queries all filter by
organization_idwith optionalcreated_atdate ranges. The existing indexes (per context snippet 3) don't include a composite(organization_id, created_at)index onapplicationSource, which would significantly improve query performance for date-filtered analytics.Consider adding a migration with:
CREATE INDEX "application_source_org_created_idx" ON "application_source" USING btree ("organization_id", "created_at");🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@server/api/source-tracking/stats.get.ts` around lines 37 - 159, The queries against applicationSource (used in the Promise.all block: channelBreakdown, dailyTrend, recentAttributed, topReferrerDomains, and the total tracked/untracked calculations) filter by organizationId and createdAt and need a composite index to speed date-filtered analytics; add a new DB migration that creates a btree composite index on (organization_id, created_at) for the application_source table (suggest a name like application_source_org_created_idx), run/verify the migration, and update any migration manifests so production/db CI applies it.
134-142: Type the raw SQL query result.Using
anyfor the query result bypasses type checking.🧹 Proposed fix
// 7. Total untracked applications (no source record) - db.execute(sql` + db.execute<{ count: string }>(sql` SELECT count(*) as count FROM ${application} a WHERE a.organization_id = ${orgId} AND NOT EXISTS ( SELECT 1 FROM ${applicationSource} s WHERE s.application_id = a.id ) - `).then((r: any) => Number(r[0]?.count ?? 0)), + `).then((r) => Number(r.rows[0]?.count ?? 0)),🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@server/api/source-tracking/stats.get.ts` around lines 134 - 142, The query result is typed as any which bypasses type checking; update the db.execute call for the COUNT query so it returns a typed array of rows (e.g., db.execute<{ count: number }[]>(sql`...`)) and then use that typed result in the mapping (replace the (r: any) parameter with a correctly typed parameter) so Number(r[0]?.count ?? 0) is type-safe; target the db.execute(sql`...`) invocation in stats.get.ts to implement this change.server/api/public/track/[code].get.ts (1)
38-44: Consider URL encoding thecodeparameter.The
codeis appended directly to the query string without encoding. While the code is base64url (URL-safe), if future code generation changes or if malformed codes reach this point, it could cause issues.🛡️ Proposed fix
const baseUrl = env.BETTER_AUTH_URL || `https://${getHeader(event, 'host')}` const targetPath = link.job?.slug - ? `/jobs/${link.job.slug}/apply?ref=${code}` - : `/jobs?ref=${code}` + ? `/jobs/${link.job.slug}/apply?ref=${encodeURIComponent(code)}` + : `/jobs?ref=${encodeURIComponent(code)}` return sendRedirect(event, `${baseUrl}${targetPath}`, 302)🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@server/api/public/track/`[code].get.ts around lines 38 - 44, The redirect builds targetPath using the raw code and appends it to the query string, which can break if code contains unsafe characters; update the construction of the targetPath (used before calling sendRedirect) to URL-encode the code (e.g., via encodeURIComponent) so the value of code is safely included in the query param; ensure you update the expression that references code (where targetPath is computed) so sendRedirect(event, `${baseUrl}${targetPath}`, 302) receives a path with the encoded code.server/api/tracking-links/index.post.ts (1)
48-54: Consider handling code collision on insert.The generated code has 48 bits of entropy, making collisions unlikely but possible at scale. The database has a UNIQUE constraint on
code, so a collision will cause a 500 error rather than a graceful retry.For robustness, consider adding retry logic or using
ON CONFLICT DO NOTHINGwith a fallback.🔄 Proposed fix with retry logic
+const MAX_CODE_RETRIES = 3 + export default defineEventHandler(async (event) => { const session = await requirePermission(event, { sourceTracking: ['create'] }) const orgId = session.session.activeOrganizationId const userId = session.user.id const body = await readValidatedBody(event, createTrackingLinkSchema.parse) // If scoped to a job, verify the job belongs to this org if (body.jobId) { const existingJob = await db.query.job.findFirst({ where: and(eq(job.id, body.jobId), eq(job.organizationId, orgId)), columns: { id: true }, }) if (!existingJob) { throw createError({ statusCode: 404, statusMessage: 'Job not found' }) } } - // Generate a unique short code (8 chars, URL-safe) - const code = generateTrackingCode() - - const [created] = await db.insert(trackingLink).values({ - organizationId: orgId, - jobId: body.jobId ?? null, - channel: body.channel, - name: body.name, - code, - utmSource: body.utmSource ?? null, - utmMedium: body.utmMedium ?? null, - utmCampaign: body.utmCampaign ?? null, - utmTerm: body.utmTerm ?? null, - utmContent: body.utmContent ?? null, - createdById: userId, - }).returning() + // Generate a unique short code with retry on collision + let created + for (let attempt = 0; attempt < MAX_CODE_RETRIES; attempt++) { + const code = generateTrackingCode() + try { + const [result] = await db.insert(trackingLink).values({ + organizationId: orgId, + jobId: body.jobId ?? null, + channel: body.channel, + name: body.name, + code, + utmSource: body.utmSource ?? null, + utmMedium: body.utmMedium ?? null, + utmCampaign: body.utmCampaign ?? null, + utmTerm: body.utmTerm ?? null, + utmContent: body.utmContent ?? null, + createdById: userId, + }).returning() + created = result + break + } catch (err: any) { + // Retry only on unique constraint violation + if (err?.code !== '23505' || attempt === MAX_CODE_RETRIES - 1) { + throw err + } + } + } setResponseStatus(event, 201) return created })🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@server/api/tracking-links/index.post.ts` around lines 48 - 54, The generateTrackingCode function can produce collisions; update the POST insert flow to handle UNIQUE constraint violations by retrying: wrap the insert that uses generateTrackingCode in a bounded retry loop (e.g., 3–5 attempts), generating a new code each attempt and breaking on success, and if your DB client supports it prefer an idempotent pattern like INSERT ... ON CONFLICT DO NOTHING followed by a SELECT/RETRY if no row was created; specifically catch the unique-constraint error from your DB client and only retry on that error (otherwise surface other errors). Ensure you reference generateTrackingCode when regenerating codes and put limits so you return a 500 or a clear error after retries are exhausted.server/database/migrations/0018_source_tracking.sql (1)
44-48: Add an org+created_at index for the stats queries.The new dashboard filters attribution by organization and date window, but this migration only adds single-column indexes.
application_source (organization_id, created_at)will keep those range scans from degrading as the table grows.Suggested fix
CREATE INDEX "application_source_organization_id_idx" ON "application_source" USING btree ("organization_id");--> statement-breakpoint +CREATE INDEX "application_source_organization_created_at_idx" ON "application_source" USING btree ("organization_id", "created_at" DESC);--> statement-breakpoint CREATE INDEX "application_source_application_id_idx" ON "application_source" USING btree ("application_id");--> statement-breakpoint🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@server/database/migrations/0018_source_tracking.sql` around lines 44 - 48, Add a composite btree index on application_source for (organization_id, created_at) to support org+date range queries used by the dashboard; specifically add a statement creating index "application_source_organization_created_at_idx" ON "application_source" USING btree ("organization_id", "created_at") alongside the existing single-column indexes so stats queries avoid full scans as the table grows.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@app/composables/useSourceTracking.ts`:
- Around line 122-136: The cache key passed to useFetch in the
useSourceTracking.ts useFetch call is made static by using `.value` on the
computed key; change the key to the computed ref itself so it stays reactive
(remove the `.value`), i.e., update the useFetch call that defines key:
computed(() => `tracking-links-${jobId.value ?? 'all'}-${channel.value ??
'all'}`) instead of using `.value`, so the key reacts to jobId and channel
changes and triggers refetches for functions/variables like useFetch, jobId,
channel and the surrounding useSourceTracking / useTrackingLinks logic.
- Around line 76-86: The cache key for the useFetch call in useSourceTracking.ts
is being made static by calling .value on the computed key; update the key
argument to pass the computed Ref itself (e.g., key: computed(() =>
`source-stats-${jobId.value ?? 'all'}-${from.value ?? ''}-${to.value ?? ''}`))
instead of `.value` so the key remains reactive to jobId/from/to changes and
triggers proper refetching (alternatively add a watch on jobId/from/to to call
refresh() on the fetch, but prefer removing `.value` on the computed key).
In `@app/pages/dashboard/source-tracking.vue`:
- Around line 63-72: The empty-state UI is shown whenever links.length === 0
even if the fetch is still in progress; update the template logic that renders
the "Create Your First Tracking Link" block to check linksStatus from
useTrackingLinks() first (e.g. show a loading spinner when linksStatus ===
'loading' or similar), and only fall back to the empty state when linksStatus
indicates success/finished and links.length === 0; apply the same change
wherever the empty-state is rendered (references: useTrackingLinks, links,
linksStatus, refresh/refreshLinks).
- Around line 171-188: The create form's select is missing supported channels
(lever and greenhouse_board) so those sources are only creatable as "custom";
update the options used by the form to include the keys "lever" and
"greenhouse_board" (which already have labels in channelLabels) so they appear
in the select. Locate the options/choices array or computed used by the create
form select (the code that renders the select and the channelLabels constant)
and add entries for lever and greenhouse_board (or ensure the options are
derived from channelLabels so all keys are included).
- Around line 75-79: The jobs list is being capped by the hardcoded query: {
limit: 100 } passed to useFetch ('useFetch' with key 'source-tracking-jobs'),
causing dashboard filters and the create-link modal to miss jobs beyond 100;
remove the fixed limit (or replace it with proper pagination/param-driven
loading) so the component either fetches all jobs or requests pages from the API
and wires the UI to load more—update the useFetch call (key:
'source-tracking-jobs') and related consumers to support unlimited or paginated
results instead of the fixed limit:100.
- Around line 983-1087: The modal Teleport controlled by showCreateModal lacks
proper dialog semantics and keyboard/focus handling; update the modal container
(the element rendered when showCreateModal is true) to include role="dialog",
aria-modal="true", and aria-labelledby pointing to the Create Tracking Link
heading (give the h2 an id), ensure the close button has an accessible label,
implement focus management so when showCreateModal becomes true focus is moved
to the initial focusable element (the input with id "link-name") and focus is
trapped inside the modal while open (restore focus to the previously focused
element on close), and add an Escape key handler to set showCreateModal = false;
apply the same changes to the other dialog referenced at 1092-1119 and ensure
background scrolling is prevented while the dialog is open.
- Around line 853-880: The action buttons are hidden via "opacity-0
group-hover:opacity-100" which makes them undiscoverable to touch and keyboard
users; update the reveal behavior to also respond to keyboard focus by adding
"group-focus-within:opacity-100" (and matching
"group-focus-within:pointer-events-auto") to the div with classes "inline-flex
items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity", and
ensure the row container that has the "group" class is keyboard-focusable (add
tabindex="0" to the row element) so focusing the row via keyboard will trigger
the group-focus-within styles and make the Copy/Toggle/Delete buttons
discoverable; also include "pointer-events-none" on the default state and
"group-hover:pointer-events-auto group-focus-within:pointer-events-auto" on the
div so hidden buttons are not tabbable until revealed.
In `@server/api/source-tracking/stats.get.ts`:
- Around line 20-32: The topLinks, totalTracked, and totalUntracked queries are
missing the job-scoped filters so they ignore the jobId used when building
whereClause; update the queries that compute topLinks, totalTracked, and
totalUntracked to include the same whereClause (or apply and(...dateConditions)
there) so they are scoped consistently with the other metrics (use the existing
whereClause variable when building those queries rather than omitting it).
In `@server/database/migrations/0018_source_tracking.sql`:
- Around line 7-13: The migration currently stores only tracking_link_id on
application_source and relies on a FK that NULLs on DELETE, which loses per-link
attribution; update the schema and deletion strategy so link identity is
preserved by either (a) making deletions soft on the tracking_link table
(add/require an is_active boolean and stop issuing hard deletes from
tracking_link), or (b) snapshotting link identifying fields onto
application_source (add columns like tracking_link_code and tracking_link_name
to application_source and populate them when inserting/updating
application_source and before any tracking_link deletion), and update any
FK/constraints referencing tracking_link to accommodate the chosen approach;
refer to the tracking_link and application_source tables and the
tracking_link_id column when making these changes.
- Line 32: The migration currently makes created_by_id non-null and ties it to a
FK with ON DELETE CASCADE so deleting a user removes tracking links and breaks
application_source references; change created_by_id to allow NULL and alter its
foreign key to use ON DELETE SET NULL (or NO ACTION) instead of CASCADE so
removing the creator does not delete the tracking link or clear references —
update the created_by_id column definition and the FK constraint(s) that
reference created_by_id and also apply the same change to the other occurrence
affecting application_source.
---
Nitpick comments:
In `@server/api/public/jobs/`[slug]/apply.post.ts:
- Around line 740-743: The loop that finds a channel for a domain redundantly
checks exact equality (d === key) even though the exact match is already handled
by the earlier if (mapping[d]) return mapping[d]!, so update the loop in the
domain-to-channel lookup to only test suffix matches (i.e., remove the d === key
branch) by changing the for (const [key, channel] of Object.entries(mapping)) {
if (d.endsWith(`.${key}`)) return channel } so exact matches remain handled by
mapping[d] and the loop only handles subdomain suffixes.
In `@server/api/public/track/`[code].get.ts:
- Around line 38-44: The redirect builds targetPath using the raw code and
appends it to the query string, which can break if code contains unsafe
characters; update the construction of the targetPath (used before calling
sendRedirect) to URL-encode the code (e.g., via encodeURIComponent) so the value
of code is safely included in the query param; ensure you update the expression
that references code (where targetPath is computed) so sendRedirect(event,
`${baseUrl}${targetPath}`, 302) receives a path with the encoded code.
In `@server/api/source-tracking/stats.get.ts`:
- Around line 37-159: The queries against applicationSource (used in the
Promise.all block: channelBreakdown, dailyTrend, recentAttributed,
topReferrerDomains, and the total tracked/untracked calculations) filter by
organizationId and createdAt and need a composite index to speed date-filtered
analytics; add a new DB migration that creates a btree composite index on
(organization_id, created_at) for the application_source table (suggest a name
like application_source_org_created_idx), run/verify the migration, and update
any migration manifests so production/db CI applies it.
- Around line 134-142: The query result is typed as any which bypasses type
checking; update the db.execute call for the COUNT query so it returns a typed
array of rows (e.g., db.execute<{ count: number }[]>(sql`...`)) and then use
that typed result in the mapping (replace the (r: any) parameter with a
correctly typed parameter) so Number(r[0]?.count ?? 0) is type-safe; target the
db.execute(sql`...`) invocation in stats.get.ts to implement this change.
In `@server/api/tracking-links/index.get.ts`:
- Line 1: The import list in server/api/tracking-links/index.get.ts includes an
unused symbol `sql` from 'drizzle-orm'; remove `sql` from the import statement
(leave `eq`, `and`, `desc`) to clean up unused imports and avoid lint warnings,
updating the import that currently reads like "import { eq, and, desc, sql }
from 'drizzle-orm'".
In `@server/api/tracking-links/index.post.ts`:
- Around line 48-54: The generateTrackingCode function can produce collisions;
update the POST insert flow to handle UNIQUE constraint violations by retrying:
wrap the insert that uses generateTrackingCode in a bounded retry loop (e.g.,
3–5 attempts), generating a new code each attempt and breaking on success, and
if your DB client supports it prefer an idempotent pattern like INSERT ... ON
CONFLICT DO NOTHING followed by a SELECT/RETRY if no row was created;
specifically catch the unique-constraint error from your DB client and only
retry on that error (otherwise surface other errors). Ensure you reference
generateTrackingCode when regenerating codes and put limits so you return a 500
or a clear error after retries are exhausted.
In `@server/database/migrations/0018_source_tracking.sql`:
- Around line 44-48: Add a composite btree index on application_source for
(organization_id, created_at) to support org+date range queries used by the
dashboard; specifically add a statement creating index
"application_source_organization_created_at_idx" ON "application_source" USING
btree ("organization_id", "created_at") alongside the existing single-column
indexes so stats queries avoid full scans as the table grows.
In `@server/utils/schemas/trackingLink.ts`:
- Around line 69-76: The UTM fields are duplicated across
createTrackingLinkSchema, updateTrackingLinkSchema, and applicationSourceSchema;
create a shared Zod partial (e.g., utmFieldsSchema = z.object({ utmSource:
z.string().max(200).optional(), utmMedium: z.string().max(200).optional(),
utmCampaign: z.string().max(200).optional(), utmTerm:
z.string().max(200).optional(), utmContent: z.string().max(200).optional() }))
and replace the repeated field definitions by merging or extending that partial
into applicationSourceSchema and into the definitions used by
createTrackingLinkSchema and updateTrackingLinkSchema (use .merge() or spread
with .extend() as appropriate) so all three schemas reference the single
utmFieldsSchema.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 9a99ad71-75f6-4dd2-aae5-4f0472f41595
📒 Files selected for processing (19)
app/components/AppTopBar.vueapp/composables/useSourceTracking.tsapp/pages/dashboard/source-tracking.vueapp/pages/jobs/[slug]/apply.vueserver/api/public/jobs/[slug]/apply.post.tsserver/api/public/track/[code].get.tsserver/api/source-tracking/stats.get.tsserver/api/tracking-links/[id].delete.tsserver/api/tracking-links/[id].get.tsserver/api/tracking-links/[id].patch.tsserver/api/tracking-links/index.get.tsserver/api/tracking-links/index.post.tsserver/database/migrations/0018_source_tracking.sqlserver/database/migrations/meta/0018_snapshot.jsonserver/database/migrations/meta/_journal.jsonserver/database/schema/app.tsserver/utils/schemas/publicApplication.tsserver/utils/schemas/trackingLink.tsshared/permissions.ts
| const { | ||
| links, | ||
| total: totalLinks, | ||
| fetchStatus: linksStatus, | ||
| createLink, | ||
| updateLink, | ||
| deleteLink, | ||
| toggleLink, | ||
| refresh: refreshLinks, | ||
| } = useTrackingLinks() |
There was a problem hiding this comment.
Render a loading state before the “no links” empty state.
linksStatus is available, but the tab switches straight to “Create Your First Tracking Link” whenever links.length === 0. If the stats request resolves before useTrackingLinks(), existing links briefly look like they do not exist.
Also applies to: 765-788
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@app/pages/dashboard/source-tracking.vue` around lines 63 - 72, The
empty-state UI is shown whenever links.length === 0 even if the fetch is still
in progress; update the template logic that renders the "Create Your First
Tracking Link" block to check linksStatus from useTrackingLinks() first (e.g.
show a loading spinner when linksStatus === 'loading' or similar), and only fall
back to the empty state when linksStatus indicates success/finished and
links.length === 0; apply the same change wherever the empty-state is rendered
(references: useTrackingLinks, links, linksStatus, refresh/refreshLinks).
| const { data: jobsData } = useFetch('/api/jobs', { | ||
| key: 'source-tracking-jobs', | ||
| headers: useRequestHeaders(['cookie']), | ||
| query: { limit: 100 }, | ||
| }) |
There was a problem hiding this comment.
Don’t cap the job selectors at the first 100 jobs.
This query feeds both the dashboard filter and the create-link modal. Any org with more than 100 jobs loses the ability to filter stats or scope a tracking link to the omitted jobs.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@app/pages/dashboard/source-tracking.vue` around lines 75 - 79, The jobs list
is being capped by the hardcoded query: { limit: 100 } passed to useFetch
('useFetch' with key 'source-tracking-jobs'), causing dashboard filters and the
create-link modal to miss jobs beyond 100; remove the fixed limit (or replace it
with proper pagination/param-driven loading) so the component either fetches all
jobs or requests pages from the API and wires the UI to load more—update the
useFetch call (key: 'source-tracking-jobs') and related consumers to support
unlimited or paginated results instead of the fixed limit:100.
| <td class="px-4 py-3.5 text-right"> | ||
| <div class="inline-flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity"> | ||
| <button | ||
| class="p-1.5 rounded-lg text-surface-400 hover:text-brand-600 dark:hover:text-brand-400 hover:bg-surface-100 dark:hover:bg-surface-800 transition-colors" | ||
| title="Copy tracking URL" | ||
| @click="copyTrackingUrl(link.code)" | ||
| > | ||
| <Copy v-if="copiedCode !== link.code" class="size-3.5" /> | ||
| <CheckCircle2 v-else class="size-3.5 text-green-500" /> | ||
| </button> | ||
| <button | ||
| v-if="canManageLinks" | ||
| class="p-1.5 rounded-lg text-surface-400 hover:text-amber-600 dark:hover:text-amber-400 hover:bg-surface-100 dark:hover:bg-surface-800 transition-colors" | ||
| :title="link.isActive ? 'Deactivate' : 'Activate'" | ||
| @click="toggleLink(link.id, !link.isActive)" | ||
| > | ||
| <ToggleRight v-if="link.isActive" class="size-3.5" /> | ||
| <ToggleLeft v-else class="size-3.5" /> | ||
| </button> | ||
| <button | ||
| v-if="canManageLinks" | ||
| class="p-1.5 rounded-lg text-surface-400 hover:text-danger-600 dark:hover:text-danger-400 hover:bg-surface-100 dark:hover:bg-surface-800 transition-colors" | ||
| title="Delete" | ||
| @click="confirmDelete(link.id)" | ||
| > | ||
| <Trash2 class="size-3.5" /> | ||
| </button> | ||
| </div> |
There was a problem hiding this comment.
Keep the row actions discoverable without hover.
These controls only become visible on group-hover. Touch devices never hover, and keyboard users can tab onto fully transparent buttons.
Suggested fix
- <div class="inline-flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
+ <div class="inline-flex items-center gap-1 opacity-100 sm:opacity-0 sm:group-hover:opacity-100 sm:group-focus-within:opacity-100 transition-opacity">📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| <td class="px-4 py-3.5 text-right"> | |
| <div class="inline-flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity"> | |
| <button | |
| class="p-1.5 rounded-lg text-surface-400 hover:text-brand-600 dark:hover:text-brand-400 hover:bg-surface-100 dark:hover:bg-surface-800 transition-colors" | |
| title="Copy tracking URL" | |
| @click="copyTrackingUrl(link.code)" | |
| > | |
| <Copy v-if="copiedCode !== link.code" class="size-3.5" /> | |
| <CheckCircle2 v-else class="size-3.5 text-green-500" /> | |
| </button> | |
| <button | |
| v-if="canManageLinks" | |
| class="p-1.5 rounded-lg text-surface-400 hover:text-amber-600 dark:hover:text-amber-400 hover:bg-surface-100 dark:hover:bg-surface-800 transition-colors" | |
| :title="link.isActive ? 'Deactivate' : 'Activate'" | |
| @click="toggleLink(link.id, !link.isActive)" | |
| > | |
| <ToggleRight v-if="link.isActive" class="size-3.5" /> | |
| <ToggleLeft v-else class="size-3.5" /> | |
| </button> | |
| <button | |
| v-if="canManageLinks" | |
| class="p-1.5 rounded-lg text-surface-400 hover:text-danger-600 dark:hover:text-danger-400 hover:bg-surface-100 dark:hover:bg-surface-800 transition-colors" | |
| title="Delete" | |
| @click="confirmDelete(link.id)" | |
| > | |
| <Trash2 class="size-3.5" /> | |
| </button> | |
| </div> | |
| <td class="px-4 py-3.5 text-right"> | |
| <div class="inline-flex items-center gap-1 opacity-100 sm:opacity-0 sm:group-hover:opacity-100 sm:group-focus-within:opacity-100 transition-opacity"> | |
| <button | |
| class="p-1.5 rounded-lg text-surface-400 hover:text-brand-600 dark:hover:text-brand-400 hover:bg-surface-100 dark:hover:bg-surface-800 transition-colors" | |
| title="Copy tracking URL" | |
| `@click`="copyTrackingUrl(link.code)" | |
| > | |
| <Copy v-if="copiedCode !== link.code" class="size-3.5" /> | |
| <CheckCircle2 v-else class="size-3.5 text-green-500" /> | |
| </button> | |
| <button | |
| v-if="canManageLinks" | |
| class="p-1.5 rounded-lg text-surface-400 hover:text-amber-600 dark:hover:text-amber-400 hover:bg-surface-100 dark:hover:bg-surface-800 transition-colors" | |
| :title="link.isActive ? 'Deactivate' : 'Activate'" | |
| `@click`="toggleLink(link.id, !link.isActive)" | |
| > | |
| <ToggleRight v-if="link.isActive" class="size-3.5" /> | |
| <ToggleLeft v-else class="size-3.5" /> | |
| </button> | |
| <button | |
| v-if="canManageLinks" | |
| class="p-1.5 rounded-lg text-surface-400 hover:text-danger-600 dark:hover:text-danger-400 hover:bg-surface-100 dark:hover:bg-surface-800 transition-colors" | |
| title="Delete" | |
| `@click`="confirmDelete(link.id)" | |
| > | |
| <Trash2 class="size-3.5" /> | |
| </button> | |
| </div> |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@app/pages/dashboard/source-tracking.vue` around lines 853 - 880, The action
buttons are hidden via "opacity-0 group-hover:opacity-100" which makes them
undiscoverable to touch and keyboard users; update the reveal behavior to also
respond to keyboard focus by adding "group-focus-within:opacity-100" (and
matching "group-focus-within:pointer-events-auto") to the div with classes
"inline-flex items-center gap-1 opacity-0 group-hover:opacity-100
transition-opacity", and ensure the row container that has the "group" class is
keyboard-focusable (add tabindex="0" to the row element) so focusing the row via
keyboard will trigger the group-focus-within styles and make the
Copy/Toggle/Delete buttons discoverable; also include "pointer-events-none" on
the default state and "group-hover:pointer-events-auto
group-focus-within:pointer-events-auto" on the div so hidden buttons are not
tabbable until revealed.
| <div class="flex items-center justify-between px-6 py-4 border-b border-surface-100 dark:border-surface-800"> | ||
| <h2 class="text-base font-semibold text-surface-900 dark:text-surface-100">Create Tracking Link</h2> | ||
| <button | ||
| class="p-1.5 rounded-lg text-surface-400 hover:text-surface-600 dark:hover:text-surface-200 hover:bg-surface-100 dark:hover:bg-surface-800 transition-colors" | ||
| @click="showCreateModal = false" | ||
| > | ||
| <X class="size-4" /> | ||
| </button> | ||
| </div> | ||
|
|
||
| <!-- Body --> | ||
| <form class="px-6 py-5 space-y-4" @submit.prevent="handleCreateLink"> | ||
| <!-- Name --> | ||
| <div> | ||
| <label for="link-name" class="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-1.5">Link Name</label> | ||
| <input | ||
| id="link-name" | ||
| v-model="newLink.name" | ||
| type="text" | ||
| placeholder="e.g. LinkedIn Spring Campaign" | ||
| class="w-full rounded-xl border border-surface-200 dark:border-surface-700 bg-white dark:bg-surface-800 px-4 py-2.5 text-sm text-surface-900 dark:text-surface-100 placeholder:text-surface-400 focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20 outline-none transition-all" | ||
| /> | ||
| </div> | ||
|
|
||
| <!-- Channel --> | ||
| <div> | ||
| <label for="link-channel" class="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-1.5">Source Channel</label> | ||
| <select | ||
| id="link-channel" | ||
| v-model="newLink.channel" | ||
| class="w-full rounded-xl border border-surface-200 dark:border-surface-700 bg-white dark:bg-surface-800 px-4 py-2.5 text-sm text-surface-900 dark:text-surface-100 focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20 outline-none transition-all" | ||
| > | ||
| <optgroup label="Job Boards"> | ||
| <option v-for="ch in ['linkedin', 'indeed', 'glassdoor', 'ziprecruiter', 'monster', 'handshake', 'angellist', 'wellfound', 'dice', 'stackoverflow', 'weworkremotely', 'remoteok', 'builtin', 'hired', 'google_jobs']" :key="ch" :value="ch">{{ getChannelLabel(ch) }}</option> | ||
| </optgroup> | ||
| <optgroup label="Social Media"> | ||
| <option v-for="ch in ['facebook', 'twitter', 'instagram', 'tiktok', 'reddit']" :key="ch" :value="ch">{{ getChannelLabel(ch) }}</option> | ||
| </optgroup> | ||
| <optgroup label="Other"> | ||
| <option v-for="ch in ['referral', 'career_site', 'email', 'event', 'agency', 'direct', 'custom', 'other']" :key="ch" :value="ch">{{ getChannelLabel(ch) }}</option> | ||
| </optgroup> | ||
| </select> | ||
| </div> | ||
|
|
||
| <!-- Job (optional) --> | ||
| <div> | ||
| <label for="link-job" class="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-1.5">Scope to Job <span class="text-surface-400 font-normal">(optional)</span></label> | ||
| <select | ||
| id="link-job" | ||
| v-model="newLink.jobId" | ||
| class="w-full rounded-xl border border-surface-200 dark:border-surface-700 bg-white dark:bg-surface-800 px-4 py-2.5 text-sm text-surface-900 dark:text-surface-100 focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20 outline-none transition-all" | ||
| > | ||
| <option value="">All jobs (org-wide)</option> | ||
| <option v-for="j in jobs" :key="j.id" :value="j.id">{{ j.title }}</option> | ||
| </select> | ||
| </div> | ||
|
|
||
| <!-- UTM fields (collapsible) --> | ||
| <details class="group"> | ||
| <summary class="flex items-center gap-2 text-sm font-medium text-surface-500 dark:text-surface-400 cursor-pointer select-none hover:text-surface-700 dark:hover:text-surface-200 transition-colors"> | ||
| <ChevronDown class="size-4 transition-transform group-open:rotate-180" /> | ||
| UTM Parameters (optional) | ||
| </summary> | ||
| <div class="mt-3 grid grid-cols-2 gap-3"> | ||
| <div> | ||
| <label for="utm-source" class="block text-xs font-medium text-surface-500 dark:text-surface-400 mb-1">utm_source</label> | ||
| <input id="utm-source" v-model="newLink.utmSource" type="text" placeholder="linkedin" class="w-full rounded-lg border border-surface-200 dark:border-surface-700 bg-white dark:bg-surface-800 px-3 py-2 text-xs text-surface-900 dark:text-surface-100 placeholder:text-surface-400 focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20 outline-none transition-all" /> | ||
| </div> | ||
| <div> | ||
| <label for="utm-medium" class="block text-xs font-medium text-surface-500 dark:text-surface-400 mb-1">utm_medium</label> | ||
| <input id="utm-medium" v-model="newLink.utmMedium" type="text" placeholder="social" class="w-full rounded-lg border border-surface-200 dark:border-surface-700 bg-white dark:bg-surface-800 px-3 py-2 text-xs text-surface-900 dark:text-surface-100 placeholder:text-surface-400 focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20 outline-none transition-all" /> | ||
| </div> | ||
| <div class="col-span-2"> | ||
| <label for="utm-campaign" class="block text-xs font-medium text-surface-500 dark:text-surface-400 mb-1">utm_campaign</label> | ||
| <input id="utm-campaign" v-model="newLink.utmCampaign" type="text" placeholder="spring-hiring-2026" class="w-full rounded-lg border border-surface-200 dark:border-surface-700 bg-white dark:bg-surface-800 px-3 py-2 text-xs text-surface-900 dark:text-surface-100 placeholder:text-surface-400 focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20 outline-none transition-all" /> | ||
| </div> | ||
| </div> | ||
| </details> | ||
|
|
||
| <!-- Footer --> | ||
| <div class="flex items-center justify-end gap-3 pt-2"> | ||
| <button | ||
| type="button" | ||
| class="rounded-xl px-4 py-2.5 text-sm font-medium text-surface-600 dark:text-surface-300 hover:bg-surface-100 dark:hover:bg-surface-800 transition-colors" | ||
| @click="showCreateModal = false" | ||
| > | ||
| Cancel | ||
| </button> | ||
| <button | ||
| type="submit" | ||
| :disabled="!newLink.name.trim() || isCreating" | ||
| class="inline-flex items-center gap-2 rounded-xl bg-brand-600 px-5 py-2.5 text-sm font-semibold text-white hover:bg-brand-700 disabled:opacity-50 shadow-sm shadow-brand-600/15 transition-all" | ||
| > | ||
| {{ isCreating ? 'Creating…' : 'Create Link' }} | ||
| </button> | ||
| </div> | ||
| </form> | ||
| </div> | ||
| </div> | ||
| </Teleport> |
There was a problem hiding this comment.
These overlays need real modal accessibility.
Both dialogs are missing dialog semantics, focus trapping/initial focus, and Escape dismissal. That lets focus move behind the overlay and leaves screen readers without a dialog context.
Also applies to: 1092-1119
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@app/pages/dashboard/source-tracking.vue` around lines 983 - 1087, The modal
Teleport controlled by showCreateModal lacks proper dialog semantics and
keyboard/focus handling; update the modal container (the element rendered when
showCreateModal is true) to include role="dialog", aria-modal="true", and
aria-labelledby pointing to the Create Tracking Link heading (give the h2 an
id), ensure the close button has an accessible label, implement focus management
so when showCreateModal becomes true focus is moved to the initial focusable
element (the input with id "link-name") and focus is trapped inside the modal
while open (restore focus to the previously focused element on close), and add
an Escape key handler to set showCreateModal = false; apply the same changes to
the other dialog referenced at 1092-1119 and ensure background scrolling is
prevented while the dialog is open.
| // Build date range conditions | ||
| const dateConditions = [eq(applicationSource.organizationId, orgId)] | ||
| if (query.jobId) { | ||
| dateConditions.push(eq(application.jobId, query.jobId)) | ||
| } | ||
| if (query.from) { | ||
| dateConditions.push(gte(applicationSource.createdAt, new Date(query.from))) | ||
| } | ||
| if (query.to) { | ||
| dateConditions.push(lte(applicationSource.createdAt, new Date(query.to))) | ||
| } | ||
|
|
||
| const whereClause = and(...dateConditions) |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Check if the frontend displays these metrics in a way that makes the inconsistency clear
rg -n -C5 'totalTracked|topLinks' app/pages/dashboard/source-tracking.vueRepository: reqcore-inc/reqcore
Length of output: 2095
🏁 Script executed:
#!/bin/bash
# View the complete stats endpoint to verify which queries use whereClause
cat -n server/api/source-tracking/stats.get.ts | sed -n '20,160p'Repository: reqcore-inc/reqcore
Length of output: 5930
🏁 Script executed:
#!/bin/bash
# Search for comments around the stats endpoint explaining filter behavior
head -n 50 server/api/source-tracking/stats.get.tsRepository: reqcore-inc/reqcore
Length of output: 1833
Address filter inconsistency in summary metrics: topLinks, totalTracked, and totalUntracked ignore the jobId filter.
The whereClause (built with optional jobId filter on lines 22-23) is applied to queries 1, 3, 4, 5, and 8, but not to queries 2 (topLinks), 6 (totalTracked), or 7 (totalUntracked). This means when a user filters by job, the summary totals and top links still reflect org-wide data, while other metrics (channel breakdown, daily trend, recent attributed) are correctly scoped.
On the dashboard, these metrics appear together: summary.totalTracked displays alongside filtered trend data, creating inconsistency. Either:
- Apply the
jobIdfilter consistently to all queries, or - Document that summary metrics intentionally show org-wide data regardless of job filter
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@server/api/source-tracking/stats.get.ts` around lines 20 - 32, The topLinks,
totalTracked, and totalUntracked queries are missing the job-scoped filters so
they ignore the jobId used when building whereClause; update the queries that
compute topLinks, totalTracked, and totalUntracked to include the same
whereClause (or apply and(...dateConditions) there) so they are scoped
consistently with the other metrics (use the existing whereClause variable when
building those queries rather than omitting it).
| "tracking_link_id" text, | ||
| "utm_source" text, | ||
| "utm_medium" text, | ||
| "utm_campaign" text, | ||
| "utm_term" text, | ||
| "utm_content" text, | ||
| "referrer_domain" text, |
There was a problem hiding this comment.
Hard deletion will erase historical per-link attribution.
application_source only keeps tracking_link_id, and this FK nulls it on delete. Since tracking_link already has is_active, a hard delete permanently drops the link identity the dashboard needs for link-level history. Prefer soft delete, or snapshot the link code/name onto application_source before allowing deletion.
Also applies to: 31-31, 40-40
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@server/database/migrations/0018_source_tracking.sql` around lines 7 - 13, The
migration currently stores only tracking_link_id on application_source and
relies on a FK that NULLs on DELETE, which loses per-link attribution; update
the schema and deletion strategy so link identity is preserved by either (a)
making deletions soft on the tracking_link table (add/require an is_active
boolean and stop issuing hard deletes from tracking_link), or (b) snapshotting
link identifying fields onto application_source (add columns like
tracking_link_code and tracking_link_name to application_source and populate
them when inserting/updating application_source and before any tracking_link
deletion), and update any FK/constraints referencing tracking_link to
accommodate the chosen approach; refer to the tracking_link and
application_source tables and the tracking_link_id column when making these
changes.
| "click_count" integer DEFAULT 0 NOT NULL, | ||
| "application_count" integer DEFAULT 0 NOT NULL, | ||
| "is_active" boolean DEFAULT true NOT NULL, | ||
| "created_by_id" text NOT NULL, |
There was a problem hiding this comment.
Don’t tie tracking-link lifetime to the creator account.
created_by_id is audit metadata. With ON DELETE CASCADE, offboarding a user deletes active tracking links, breaks their public URLs, and strips related application_source rows of their link reference.
Suggested fix
- "created_by_id" text NOT NULL,
+ "created_by_id" text,
@@
-ALTER TABLE "tracking_link" ADD CONSTRAINT "tracking_link_created_by_id_user_id_fk" FOREIGN KEY ("created_by_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
+ALTER TABLE "tracking_link" ADD CONSTRAINT "tracking_link_created_by_id_user_id_fk" FOREIGN KEY ("created_by_id") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpointAlso applies to: 43-43
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@server/database/migrations/0018_source_tracking.sql` at line 32, The
migration currently makes created_by_id non-null and ties it to a FK with ON
DELETE CASCADE so deleting a user removes tracking links and breaks
application_source references; change created_by_id to allow NULL and alter its
foreign key to use ON DELETE SET NULL (or NO ACTION) instead of CASCADE so
removing the creator does not delete the tracking link or clear references —
update the created_by_id column definition and the FK constraint(s) that
reference created_by_id and also apply the same change to the other occurrence
affecting application_source.
- Updated source-tracking.vue to use new permission handling for link management. - Enhanced job detail page to forward source-tracking query parameters (?ref=, utm_*) to the apply page. - Modified job listing page to ensure source-tracking parameters are included in navigation to job details. - Adjusted apply API endpoint to correctly handle and store source-tracking parameters. - Refactored tracking link API endpoints to correct import paths. - Added e2e tests to verify that source-tracking parameters propagate correctly from job listing to application submission.
There was a problem hiding this comment.
Actionable comments posted: 2
♻️ Duplicate comments (5)
app/pages/dashboard/source-tracking.vue (5)
74-78:⚠️ Potential issue | 🟠 MajorRemove the hard cap of 100 jobs from selector data.
This still truncates job choices for orgs with >100 jobs, which breaks both filtering and create-link scoping.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@app/pages/dashboard/source-tracking.vue` around lines 74 - 78, The useFetch call for jobs in source-tracking.vue is hard-capped by query: { limit: 100 } which truncates organizations with more than 100 jobs; remove the limit parameter from the options passed to useFetch (the const { data: jobsData } = useFetch('/api/jobs', { key: 'source-tracking-jobs', headers: useRequestHeaders(['cookie']), query: { limit: 100 }, }) call) so the full job list is returned (or pass a server-respected parameter like page/offset if you intend to paginate instead), and verify downstream code that consumes jobsData (selector, filtering, create-link scoping) still works with the full dataset.
1020-1027:⚠️ Potential issue | 🟡 MinorInclude all supported channels in the create form options.
leverandgreenhouse_boardare still missing from selectable create options.Suggested patch
- <option v-for="ch in ['linkedin', 'indeed', 'glassdoor', 'ziprecruiter', 'monster', 'handshake', 'angellist', 'wellfound', 'dice', 'stackoverflow', 'weworkremotely', 'remoteok', 'builtin', 'hired', 'google_jobs']" :key="ch" :value="ch">{{ getChannelLabel(ch) }}</option> + <option v-for="ch in ['linkedin', 'indeed', 'glassdoor', 'ziprecruiter', 'monster', 'handshake', 'angellist', 'wellfound', 'dice', 'stackoverflow', 'weworkremotely', 'remoteok', 'builtin', 'hired', 'lever', 'greenhouse_board', 'google_jobs']" :key="ch" :value="ch">{{ getChannelLabel(ch) }}</option>🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@app/pages/dashboard/source-tracking.vue` around lines 1020 - 1027, The create form's channel <option> lists are missing "lever" and "greenhouse_board"; update the v-for arrays used for the optgroup options (the arrays feeding the v-for in the template where getChannelLabel(ch) is used) to include "lever" and "greenhouse_board" in the appropriate groups so those channels become selectable in the create form (ensure the strings are added to the same arrays referenced by the v-for and that getChannelLabel can resolve their labels).
765-787:⚠️ Potential issue | 🟡 MinorGate the links empty state on
linksStatus, not onlylinks.length.The UI can still flash “Create Your First Tracking Link” while links are loading.
Suggested patch
- <div v-if="links.length === 0" class="flex flex-col items-center justify-center py-20"> + <div v-if="linksStatus === 'pending'" class="flex items-center justify-center py-20 text-sm text-surface-500 dark:text-surface-400"> + Loading tracking links... + </div> + <div v-else-if="links.length === 0" class="flex flex-col items-center justify-center py-20"> <div class="rounded-3xl border border-surface-200 dark:border-surface-800 bg-white dark:bg-surface-900 p-14 text-center max-w-md shadow-sm"> ... </div> </div> - <div v-else class="rounded-2xl border border-surface-200/80 dark:border-surface-800 bg-white dark:bg-surface-900 overflow-hidden shadow-xs dark:shadow-none"> + <div v-else class="rounded-2xl border border-surface-200/80 dark:border-surface-800 bg-white dark:bg-surface-900 overflow-hidden shadow-xs dark:shadow-none">🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@app/pages/dashboard/source-tracking.vue` around lines 765 - 787, The empty-state render is only gated by links.length which lets the "Create Your First Tracking Link" UI flash while data is loading; update the v-if condition on the empty-state container to also check the load status (use the existing linksStatus variable) so the empty state only shows when links are empty AND linksStatus is not loading (e.g. change v-if="links.length === 0" to v-if="links.length === 0 && linksStatus !== 'loading'" or to an explicit success/idle status), leaving the existing v-else block intact and keeping the canManageLinks and showCreateModal bindings as-is.
982-1086:⚠️ Potential issue | 🟠 MajorBoth modals still need proper dialog semantics and keyboard/focus behavior.
They still lack dialog roles/labels and robust keyboard/focus management (Escape + trap + restore focus).
Suggested patch (semantics + Escape baseline)
- <div v-if="showCreateModal" class="fixed inset-0 z-50 flex items-center justify-center p-4"> + <div + v-if="showCreateModal" + class="fixed inset-0 z-50 flex items-center justify-center p-4" + role="dialog" + aria-modal="true" + aria-labelledby="create-tracking-link-title" + `@keydown.esc.window`="showCreateModal = false" + > ... - <h2 class="text-base font-semibold text-surface-900 dark:text-surface-100">Create Tracking Link</h2> + <h2 id="create-tracking-link-title" class="text-base font-semibold text-surface-900 dark:text-surface-100">Create Tracking Link</h2> <button class="p-1.5 rounded-lg text-surface-400 hover:text-surface-600 dark:hover:text-surface-200 hover:bg-surface-100 dark:hover:bg-surface-800 transition-colors" + aria-label="Close create tracking link dialog" `@click`="showCreateModal = false" > ... - <div v-if="showDeleteConfirm" class="fixed inset-0 z-50 flex items-center justify-center p-4"> + <div + v-if="showDeleteConfirm" + class="fixed inset-0 z-50 flex items-center justify-center p-4" + role="dialog" + aria-modal="true" + aria-labelledby="delete-tracking-link-title" + `@keydown.esc.window`="showDeleteConfirm = false" + > ... - <h3 class="text-base font-semibold text-surface-900 dark:text-surface-100 mb-2">Delete Tracking Link?</h3> + <h3 id="delete-tracking-link-title" class="text-base font-semibold text-surface-900 dark:text-surface-100 mb-2">Delete Tracking Link?</h3>Also applies to: 1091-1118
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@app/pages/dashboard/source-tracking.vue` around lines 982 - 1086, The modal Teleport block that uses showCreateModal (around the Create Tracking Link form and submit handler handleCreateLink) lacks dialog semantics and keyboard/focus behavior; add role="dialog" and aria-modal="true" on the modal container and aria-labelledby pointing to the header H2 (give the H2 a stable id), implement Escape handling to set showCreateModal = false, implement a focus trap when showCreateModal is true (move tab focus inside the dialog) and set initial focus to the first input (link-name) while saving and restoring the previously focused element when the dialog closes; apply the same changes to the other modal block referenced (lines 1091-1118) so both modals share consistent semantics and restore focus on close.
853-879:⚠️ Potential issue | 🟠 MajorMake row actions discoverable without hover.
These controls are still hover-only; touch users and keyboard users get poor discoverability.
Suggested patch
- <div class="inline-flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity"> + <div class="inline-flex items-center gap-1 opacity-100 sm:opacity-0 sm:group-hover:opacity-100 sm:group-focus-within:opacity-100 pointer-events-auto sm:pointer-events-none sm:group-hover:pointer-events-auto sm:group-focus-within:pointer-events-auto transition-opacity">🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@app/pages/dashboard/source-tracking.vue` around lines 853 - 879, The row action buttons are hidden via "opacity-0 group-hover:opacity-100", which prevents touch and keyboard users from discovering them; update the container div (currently using copiedCode/canManageLinks, and handlers copyTrackingUrl, toggleLink, confirmDelete) to make controls visible and focusable by replacing the hover-only classes with something like a low default opacity plus hover/focus/focus-within rules (e.g., use "opacity-60 hover:opacity-100 focus-within:opacity-100 focus-visible:opacity-100" or similar) so buttons are always perceivable on touch and keyboard, while keeping hover/focus styles for emphasis. Ensure you do not remove the buttons themselves (Copy, CheckCircle2, ToggleRight/Left, Trash2) and that existing `@click` handlers and v-if conditions remain unchanged.
🧹 Nitpick comments (3)
app/pages/dashboard/jobs/new.vue (1)
374-394: Missing error handling for malformed API response.If the
/api/tracking-linksendpoint returns an unexpected shape (missingcodeorid), the code will silently fail or produce incorrect URLs. Consider adding validation.💡 Add response validation
async function createChannelLink(channel: string, channelName: string) { if (createdLinks.value[channel]?.code) return createdLinks.value[channel] = { code: '', url: '', loading: true, copied: false } try { const result = await $fetch<{ id: string; code: string }>('/api/tracking-links', { method: 'POST', body: { jobId: createdJobId.value, channel, name: `${form.value.title} — ${channelName}`, }, }) + if (!result?.code) { + throw new Error('Invalid response: missing tracking code') + } const base = `${requestUrl.protocol}//${requestUrl.host}` const trackUrl = `${base}/api/public/track/${encodeURIComponent(result.code)}`🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@app/pages/dashboard/jobs/new.vue` around lines 374 - 394, The createChannelLink function assumes $fetch('/api/tracking-links') returns {id, code} but doesn't validate the response shape; add explicit validation after the fetch to ensure result && typeof result.code === 'string' && typeof result.id === 'string' (or at least result.code exists) and throw or go to the catch path if invalid, so you don't build an incorrect trackUrl or set createdLinks with empty/invalid values; ensure createdLinks.value[channel] is deleted or updated to loading:false and toast.error is shown when validation fails, and include a safe guard before using result.code to build trackUrl and calling track('tracking_link_created').e2e/critical-flows/source-tracking.spec.ts (1)
130-133:postDataJSON()assumes JSON body, which may fail for multipart submissions.If the apply form submits with
multipart/form-data(e.g., when a resume is uploaded),postDataJSON()will fail. Since this test disables resume requirement, it should work, but the assumption is fragile if test setup changes.Consider adding a defensive check or documenting this assumption.
💡 Add defensive handling for request body type
// Verify the POST body included the tracking params - const requestBody = applyResponse.request().postDataJSON() - expect(requestBody.ref, 'POST body must include ref code').toBe(REF_CODE) - expect(requestBody.utmSource, 'POST body must include utmSource').toBe(UTM_SOURCE) + const contentType = applyResponse.request().headers()['content-type'] ?? '' + if (contentType.includes('application/json')) { + const requestBody = applyResponse.request().postDataJSON() + expect(requestBody.ref, 'POST body must include ref code').toBe(REF_CODE) + expect(requestBody.utmSource, 'POST body must include utmSource').toBe(UTM_SOURCE) + } else { + // Multipart form — verify via postData() string contains the values + const postData = applyResponse.request().postData() ?? '' + expect(postData, 'POST body must include ref code').toContain(REF_CODE) + expect(postData, 'POST body must include utmSource').toContain(UTM_SOURCE) + }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@e2e/critical-flows/source-tracking.spec.ts` around lines 130 - 133, The test assumes JSON by calling applyResponse.request().postDataJSON(), which will throw for multipart/form-data; update the assertion to first inspect the request content type (via applyResponse.request().headers()['content-type'] or raw applyResponse.request().postData()), then: if content-type includes 'application/json' call postDataJSON() and assert requestBody.ref/utmSource, otherwise parse the raw postData() payload (use URLSearchParams for x-www-form-urlencoded or extract field boundaries/key-values for multipart/form-data) and assert the ref and utmSource values; alternatively wrap postDataJSON() in try/catch and fall back to parsing postData() so the test remains resilient if the form encoding changes.server/api/public/jobs/[slug]/apply.post.ts (1)
669-707: Consider returning'other'for unknown UTM sources instead ofnull.When
mapUtmToChannelreceives an unrecognizedutm_source, it returnsnull, which cascades to the referrer check and potentially falls back to'direct'. However, if a UTM source is explicitly provided but unrecognized,'other'(which exists in the enum per context snippet 2) might be more semantically accurate than eventually defaulting to'direct'.This is minor since the current fallback chain works correctly, but could improve attribution accuracy.
💡 Optional enhancement
} - return mapping[source] ?? null + return mapping[source] ?? 'other' }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@server/api/public/jobs/`[slug]/apply.post.ts around lines 669 - 707, The function mapUtmToChannel currently returns null for unknown but present utm_source values; change its behavior so that when utmSource is undefined it still returns null, but when utmSource is provided and not found in the mapping it returns the semantic fallback 'other' (instead of null) so attribution doesn't collapse to 'direct'; update mapUtmToChannel to use mapping[source] ?? 'other' while keeping the initial if (!utmSource) return null check and reference the function name mapUtmToChannel and the mapping constant in your change.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@app/pages/dashboard/index.vue`:
- Line 47: Replace the millisecond addition with calendar-safe arithmetic:
create a copy of today and call setDate(getDate() + 7) so weekFromToday advances
by 7 calendar days (respecting DST) instead of adding a fixed millisecond
offset; update the expression that defines weekFromToday (currently using
today.getTime() + 7 * 24 * 60 * 60 * 1000) to use the copy/setDate/getDate
approach to avoid DST shifts.
In `@app/pages/dashboard/jobs/new.vue`:
- Around line 417-448: The client generates channel values like
`custom_{sanitizedName}` in createCustomBoardLink but the server Zod schema
(used by createTrackingLinkSchema) only allows the exact enum 'custom', so
change the payload to send channel: 'custom' (keep the sanitized identifier only
for local uniqueness/UI or include it in the name/metadata) and update the local
duplicate check in createCustomBoardLink to compare the sanitized identifier
(currently stored in the local variable channel) against a different property
(e.g., compare against a new local field like `subChannel` or the `name`/`url`),
ensuring the POST body uses channel: 'custom' while preserving the unique
identifier elsewhere so validation passes and duplicates are still prevented.
---
Duplicate comments:
In `@app/pages/dashboard/source-tracking.vue`:
- Around line 74-78: The useFetch call for jobs in source-tracking.vue is
hard-capped by query: { limit: 100 } which truncates organizations with more
than 100 jobs; remove the limit parameter from the options passed to useFetch
(the const { data: jobsData } = useFetch('/api/jobs', { key:
'source-tracking-jobs', headers: useRequestHeaders(['cookie']), query: { limit:
100 }, }) call) so the full job list is returned (or pass a server-respected
parameter like page/offset if you intend to paginate instead), and verify
downstream code that consumes jobsData (selector, filtering, create-link
scoping) still works with the full dataset.
- Around line 1020-1027: The create form's channel <option> lists are missing
"lever" and "greenhouse_board"; update the v-for arrays used for the optgroup
options (the arrays feeding the v-for in the template where getChannelLabel(ch)
is used) to include "lever" and "greenhouse_board" in the appropriate groups so
those channels become selectable in the create form (ensure the strings are
added to the same arrays referenced by the v-for and that getChannelLabel can
resolve their labels).
- Around line 765-787: The empty-state render is only gated by links.length
which lets the "Create Your First Tracking Link" UI flash while data is loading;
update the v-if condition on the empty-state container to also check the load
status (use the existing linksStatus variable) so the empty state only shows
when links are empty AND linksStatus is not loading (e.g. change
v-if="links.length === 0" to v-if="links.length === 0 && linksStatus !==
'loading'" or to an explicit success/idle status), leaving the existing v-else
block intact and keeping the canManageLinks and showCreateModal bindings as-is.
- Around line 982-1086: The modal Teleport block that uses showCreateModal
(around the Create Tracking Link form and submit handler handleCreateLink) lacks
dialog semantics and keyboard/focus behavior; add role="dialog" and
aria-modal="true" on the modal container and aria-labelledby pointing to the
header H2 (give the H2 a stable id), implement Escape handling to set
showCreateModal = false, implement a focus trap when showCreateModal is true
(move tab focus inside the dialog) and set initial focus to the first input
(link-name) while saving and restoring the previously focused element when the
dialog closes; apply the same changes to the other modal block referenced (lines
1091-1118) so both modals share consistent semantics and restore focus on close.
- Around line 853-879: The row action buttons are hidden via "opacity-0
group-hover:opacity-100", which prevents touch and keyboard users from
discovering them; update the container div (currently using
copiedCode/canManageLinks, and handlers copyTrackingUrl, toggleLink,
confirmDelete) to make controls visible and focusable by replacing the
hover-only classes with something like a low default opacity plus
hover/focus/focus-within rules (e.g., use "opacity-60 hover:opacity-100
focus-within:opacity-100 focus-visible:opacity-100" or similar) so buttons are
always perceivable on touch and keyboard, while keeping hover/focus styles for
emphasis. Ensure you do not remove the buttons themselves (Copy, CheckCircle2,
ToggleRight/Left, Trash2) and that existing `@click` handlers and v-if conditions
remain unchanged.
---
Nitpick comments:
In `@app/pages/dashboard/jobs/new.vue`:
- Around line 374-394: The createChannelLink function assumes
$fetch('/api/tracking-links') returns {id, code} but doesn't validate the
response shape; add explicit validation after the fetch to ensure result &&
typeof result.code === 'string' && typeof result.id === 'string' (or at least
result.code exists) and throw or go to the catch path if invalid, so you don't
build an incorrect trackUrl or set createdLinks with empty/invalid values;
ensure createdLinks.value[channel] is deleted or updated to loading:false and
toast.error is shown when validation fails, and include a safe guard before
using result.code to build trackUrl and calling track('tracking_link_created').
In `@e2e/critical-flows/source-tracking.spec.ts`:
- Around line 130-133: The test assumes JSON by calling
applyResponse.request().postDataJSON(), which will throw for
multipart/form-data; update the assertion to first inspect the request content
type (via applyResponse.request().headers()['content-type'] or raw
applyResponse.request().postData()), then: if content-type includes
'application/json' call postDataJSON() and assert requestBody.ref/utmSource,
otherwise parse the raw postData() payload (use URLSearchParams for
x-www-form-urlencoded or extract field boundaries/key-values for
multipart/form-data) and assert the ref and utmSource values; alternatively wrap
postDataJSON() in try/catch and fall back to parsing postData() so the test
remains resilient if the form encoding changes.
In `@server/api/public/jobs/`[slug]/apply.post.ts:
- Around line 669-707: The function mapUtmToChannel currently returns null for
unknown but present utm_source values; change its behavior so that when
utmSource is undefined it still returns null, but when utmSource is provided and
not found in the mapping it returns the semantic fallback 'other' (instead of
null) so attribution doesn't collapse to 'direct'; update mapUtmToChannel to use
mapping[source] ?? 'other' while keeping the initial if (!utmSource) return null
check and reference the function name mapUtmToChannel and the mapping constant
in your change.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 93163356-fce3-4f4a-878f-0533161b18e8
📒 Files selected for processing (12)
app/composables/useSourceTracking.tsapp/pages/dashboard/index.vueapp/pages/dashboard/jobs/new.vueapp/pages/dashboard/source-tracking.vueapp/pages/jobs/[slug]/index.vueapp/pages/jobs/index.vuee2e/critical-flows/source-tracking.spec.tsserver/api/public/jobs/[slug]/apply.post.tsserver/api/public/track/[code].get.tsserver/api/tracking-links/[id].delete.tsserver/api/tracking-links/[id].get.tsserver/api/tracking-links/[id].patch.ts
✅ Files skipped from review due to trivial changes (1)
- app/composables/useSourceTracking.ts
🚧 Files skipped from review as they are similar to previous changes (1)
- server/api/public/track/[code].get.ts
| // Truncate to start-of-day so the useFetch key is identical on server & client | ||
| // (prevents SSR/hydration mismatch from sub-second timestamp drift) | ||
| const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()) | ||
| const weekFromToday = new Date(today.getTime() + 7 * 24 * 60 * 60 * 1000) |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Verify DST edge behavior in JS Date arithmetic.
TZ=America/New_York node <<'NODE'
const today = new Date(2026, 9, 31, 0, 0, 0, 0) // Oct 31, 2026 local
const fixedMs = new Date(today.getTime() + 7 * 24 * 60 * 60 * 1000)
const calendar = new Date(today)
calendar.setDate(calendar.getDate() + 7)
console.log('today :', today.toString())
console.log('fixed ms +7:', fixedMs.toString())
console.log('setDate +7 :', calendar.toString())
NODERepository: reqcore-inc/reqcore
Length of output: 275
🏁 Script executed:
git ls-files | grep -E "(dashboard|index\.vue)" | head -20Repository: reqcore-inc/reqcore
Length of output: 883
🏁 Script executed:
head -60 app/pages/dashboard/index.vue | tail -30Repository: reqcore-inc/reqcore
Length of output: 921
Use calendar arithmetic instead of fixed milliseconds for +7 days.
At line 47, adding 7 * 24 * 60 * 60 * 1000 milliseconds shifts the boundary by an hour when crossing DST transitions (e.g., Nov 6 23:00 instead of Nov 7 00:00), causing the date range query to include or exclude interviews incorrectly.
🛠️ Proposed fix
-const weekFromToday = new Date(today.getTime() + 7 * 24 * 60 * 60 * 1000)
+const weekFromToday = new Date(today)
+weekFromToday.setDate(weekFromToday.getDate() + 7)📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| const weekFromToday = new Date(today.getTime() + 7 * 24 * 60 * 60 * 1000) | |
| const weekFromToday = new Date(today) | |
| weekFromToday.setDate(weekFromToday.getDate() + 7) |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@app/pages/dashboard/index.vue` at line 47, Replace the millisecond addition
with calendar-safe arithmetic: create a copy of today and call setDate(getDate()
+ 7) so weekFromToday advances by 7 calendar days (respecting DST) instead of
adding a fixed millisecond offset; update the expression that defines
weekFromToday (currently using today.getTime() + 7 * 24 * 60 * 60 * 1000) to use
the copy/setDate/getDate approach to avoid DST shifts.
There was a problem hiding this comment.
🧹 Nitpick comments (1)
app/pages/jobs/[slug]/index.vue (1)
12-22: Harden query parameter normalization to handle arrays from repeated params.On lines 15–20,
route.query.* as stringassertions can mask array or null values from Vue Router's query parsing. When a URL contains repeated parameters (e.g.,?ref=a&ref=b), route.query returns arrays, and the unsafe cast produces malformed values forwarded to the apply page.🔧 Suggested hardening
const applyQuery = computed(() => { + const firstQueryValue = (value: unknown): string | undefined => { + if (Array.isArray(value)) return typeof value[0] === 'string' ? value[0] : undefined + return typeof value === 'string' ? value : undefined + } + const q: Record<string, string> = {} - if (route.query.ref) q.ref = route.query.ref as string - if (route.query.utm_source) q.utm_source = route.query.utm_source as string - if (route.query.utm_medium) q.utm_medium = route.query.utm_medium as string - if (route.query.utm_campaign) q.utm_campaign = route.query.utm_campaign as string - if (route.query.utm_term) q.utm_term = route.query.utm_term as string - if (route.query.utm_content) q.utm_content = route.query.utm_content as string + const ref = firstQueryValue(route.query.ref) + const utmSource = firstQueryValue(route.query.utm_source) + const utmMedium = firstQueryValue(route.query.utm_medium) + const utmCampaign = firstQueryValue(route.query.utm_campaign) + const utmTerm = firstQueryValue(route.query.utm_term) + const utmContent = firstQueryValue(route.query.utm_content) + + if (ref) q.ref = ref + if (utmSource) q.utm_source = utmSource + if (utmMedium) q.utm_medium = utmMedium + if (utmCampaign) q.utm_campaign = utmCampaign + if (utmTerm) q.utm_term = utmTerm + if (utmContent) q.utm_content = utmContent return q })🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@app/pages/jobs/`[slug]/index.vue around lines 12 - 22, The computed applyQuery currently casts route.query.* values to string which breaks when query params are arrays; update the applyQuery computed function to normalize each parameter from route.query by checking Array.isArray(route.query.<param>) and using the first element (or fallback to empty string/undefined) or by coercing non-string values to string only after this check, ensuring route.query.ref, utm_source, utm_medium, utm_campaign, utm_term, and utm_content are safely extracted from arrays/nulls before assigning into the returned q object; reference the applyQuery computed and route.query usage to locate and change the normalization logic.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Nitpick comments:
In `@app/pages/jobs/`[slug]/index.vue:
- Around line 12-22: The computed applyQuery currently casts route.query.*
values to string which breaks when query params are arrays; update the
applyQuery computed function to normalize each parameter from route.query by
checking Array.isArray(route.query.<param>) and using the first element (or
fallback to empty string/undefined) or by coercing non-string values to string
only after this check, ensuring route.query.ref, utm_source, utm_medium,
utm_campaign, utm_term, and utm_content are safely extracted from arrays/nulls
before assigning into the returned q object; reference the applyQuery computed
and route.query usage to locate and change the normalization logic.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 7804c548-a9b4-459b-9592-e734e133c0ea
📒 Files selected for processing (4)
app/pages/jobs/[slug]/index.vueapp/pages/jobs/index.vuenuxt.config.tsserver/plugins/posthog.ts
✅ Files skipped from review due to trivial changes (1)
- app/pages/jobs/index.vue
…dpoint - Implemented a new API endpoint for fetching activity log entries related to a specific candidate, including direct events and application-related events. - Added validation for query parameters using Zod. - Enriched activity log entries with candidate names and job titles. - Created a new API endpoint for detailed analytics of tracking links, including metadata, daily trends, application status breakdown, and attributed applications. - Added unit tests for the candidate timeline query schema and timeline display helpers, ensuring proper validation and functionality.
There was a problem hiding this comment.
Actionable comments posted: 6
♻️ Duplicate comments (4)
app/pages/dashboard/source-tracking/index.vue (4)
63-72:⚠️ Potential issue | 🟡 MinorWait for
linksStatusbefore rendering link counts or the empty state.Line 66 already gives you
linksStatus, but the Active Links KPI and the “Create Your First Tracking Link” branch still treat an unresolved links request as0. That briefly shows the wrong count and empty state for orgs that already have links, and it makes fetch failures look like “no data”.Also applies to: 454-466, 781-802
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@app/pages/dashboard/source-tracking/index.vue` around lines 63 - 72, The Active Links KPI and the "Create Your First Tracking Link" empty-state are rendering counts/empty when links are still loading; update the UI to wait for linksStatus (from useTrackingLinks) to reflect a settled fetch before using links or totalLinks—e.g., only show the numeric KPI or the "no links" branch when linksStatus === 'success' (or not 'loading'), otherwise render a loading/skeleton state or nothing; apply this guard to all places reading links/totalLinks (the KPI component and the create-first-link branch, plus the other occurrences referenced) so unresolved or errored fetches don't render as zero/empty.
75-79:⚠️ Potential issue | 🟠 MajorDon’t cap the job selectors at the first 100 jobs.
This query feeds both the page-level job filter and the “Scope to Job” selector in the create-link modal. Orgs with more than 100 jobs lose the ability to filter analytics or scope a new link to the omitted jobs. Either remove the fixed cap or page/load more here.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@app/pages/dashboard/source-tracking/index.vue` around lines 75 - 79, The query fetching jobs uses useFetch('/api/jobs', { key: 'source-tracking-jobs', headers: useRequestHeaders(['cookie']), query: { limit: 100 } }) which artificially caps results at 100; remove the fixed query.limit or replace it with a pagination/loading strategy so the page-level job filter and the "Scope to Job" selector see all jobs (e.g., remove query.limit or implement incremental loading with page/offset params and a "load more" or fetch-all option in the component that uses the 'source-tracking-jobs' data).
820-901:⚠️ Potential issue | 🟠 MajorKeep row actions discoverable without hover.
These buttons only appear on
group-hover. Touch users never hover, and keyboard users can tab onto fully transparent controls. Make them visible by default on small screens and reveal them ongroup-focus-withinas well.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@app/pages/dashboard/source-tracking/index.vue` around lines 820 - 901, The action buttons container currently uses "opacity-0 group-hover:opacity-100" which hides controls for touch and keyboard users; update the div with class "inline-flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity" to make actions visible by default on small screens and also reveal on group-focus-within: replace its classes with something like "inline-flex items-center gap-1 opacity-100 sm:opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 transition-opacity" so mobile users see buttons and keyboard users see them when the row receives focus; verify this change for the div that contains the Copy/Toggle/Delete buttons (associated with copiedCode, copyTrackingUrl, toggleLink, confirmDelete and canManageLinks).
1019-1123:⚠️ Potential issue | 🟠 MajorBoth teleported overlays still need real modal accessibility.
The create and delete dialogs are still plain divs: no
role="dialog"/aria-modal, no labelled dialog title, no initial focus or focus trap, and no Escape handling. That leaves focus behind the overlay and gives screen readers no dialog context.Also applies to: 1128-1155
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@app/pages/dashboard/source-tracking/index.vue` around lines 1019 - 1123, The create (Teleport using showCreateModal, form with handleCreateLink, title h2 and input id="link-name") and delete dialogs must be made accessible: add role="dialog" and aria-modal="true" on the dialog container and give the title an id (e.g., id on the h2) and set aria-labelledby to that id; when showCreateModal/showDeleteModal opens, move initial focus into the dialog (e.g., focus the first interactive element like input#link-name or the primary confirm button) and trap focus inside until closed; add an Escape key handler to close the modal (set showCreateModal=false / showDeleteModal=false) and restore focus to the element that opened the dialog; also ensure the background content is hidden from assistive tech (e.g., set aria-hidden on the main app container or use inert) while the modal is open.
🧹 Nitpick comments (3)
app/pages/dashboard/ai-analysis.vue (1)
189-283: Consider extracting a reusable stat-card component to reduce duplication.The five cards share nearly identical structure/classes with only small differences (icon, accent color, value, subtitle). A shared component would make future visual/system changes safer and faster.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@app/pages/dashboard/ai-analysis.vue` around lines 189 - 283, The dashboard has five near-identical stat card blocks; extract a reusable StatCard component and replace the repeated markup in ai-analysis.vue with it. Create a StatCard that accepts props like title (e.g., "Total Runs"), value (or a value slot), icon component, accent classes (for gradient/hover color), small subtitle/description, and an optional footer slot for conditional content (used for the pricing block); update usages to pass existing expressions/refs (formatNumber(summary.totalRuns), successRate, formatCost(totalCost), summary.completedRuns, summary.failedRuns, pricing.configured, etc.) and preserve current class/transition behavior by moving conditional :class logic into props. Ensure the new component exposes the same DOM structure so utilities like group-hover and absolute icons continue to work.tests/unit/candidate-timeline.test.ts (1)
8-128: This suite is mostly testing cloned logic, not the feature itself.
querySchema, the timeline helper functions, and the lazy-load/reset behavior are recreated inside the spec instead of imported or exercised through the actual endpoint/component code. A regression inserver/api/activity-log/candidate-timeline.get.ts,app/components/CandidateDetailSidebar.vue, orapp/pages/dashboard/jobs/[id]/index.vuecan therefore leave this file green. Please extract shared schema/helpers into an importable module, or mount the real component/composable and drive the actual watchers.Also applies to: 259-345
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@tests/unit/candidate-timeline.test.ts` around lines 8 - 128, The test recreates core logic locally (querySchema, getTimelineActionColor, describeTimelineItem, makeEntry) instead of importing the canonical implementations, so extract the shared schema/helpers into a single exportable module (or import the existing implementations from server/api/activity-log/candidate-timeline.get.ts and app/components/CandidateDetailSidebar.vue) and update the test to import querySchema, getTimelineActionColor, describeTimelineItem and makeEntry from that module; alternatively, replace the unit-level recreation by mounting the real component/composable (e.g., CandidateDetailSidebar or the candidate-timeline composable) in the test and exercising its watchers/endpoint calls so the spec drives the actual code paths rather than cloned logic.app/pages/dashboard/jobs/new.vue (1)
1553-1746: Consider rendering the three distribution groups from one shared template/component.The card body is duplicated almost verbatim for
job_board,outreach, andsocial. Any behavior or copy change now has to be kept in sync in three places. GroupingdistributionChannelsby category and rendering one reusable section/card would make this flow much cheaper to maintain.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@app/pages/dashboard/jobs/new.vue` around lines 1553 - 1746, The template duplicates the same card markup for each category; refactor by grouping distributionChannels by category and rendering a single reusable component (e.g., ChannelGroup) or template that accepts props: channels (array), createdLinks, createChannelLink, copyChannelLink, channelIcons, and defaultIcon (Globe). Replace the three v-for blocks that filter by c.category === 'job_board'/'outreach'/'social' with a single loop over Object.entries(groupedChannels) (or an array of categories) that renders <ChannelGroup :channels="channels" :created-links="createdLinks" :channel-icons="channelIcons" `@create`="createChannelLink" `@copy`="copyChannelLink" />, and move the duplicated UI (input, buttons, loading states, classes and checks for createdLinks[ch.channel]?.code/copied/loading) into that component/template so behavior remains identical while removing repetition.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@app/components/CandidateDetailSidebar.vue`:
- Around line 361-383: The timeline loader currently only watches activeTab and
can accept stale fetch results when props.applicationId/candidateId changes;
update loadTimeline (and the watcher) to key load/loaded state by the resolved
candidateId: at start of loadTimeline capture const resolvedCandidateId =
candidateId.value, bail if falsy, set a per-request flag (or store
resolvedCandidateId in timelineLoadedForCandidate), then after the fetch verify
candidateId.value === resolvedCandidateId before assigning timelineItems.value,
timelineLoaded.value and clearing timelineError; also update the watch to
trigger when candidateId/applicationId changes (or include candidateId in the
watch) so reopening the timeline for a new candidate forces a new load and
ignores stale responses.
In `@app/pages/dashboard/ai-analysis.vue`:
- Line 191: The large decorative Activity icon component and other decorative
icons (e.g., the Activity instances at the positions noted) should be hidden
from assistive tech by adding aria-hidden="true" to those icon elements;
additionally, for icon-only metric displays such as completedRuns and
failedRuns, add an accessible label (e.g., aria-label or visually-hidden text)
to the metric container or the icon so screen readers see a clear description
(for example, aria-label="Completed runs: X" on the element rendering
completedRuns and similarly for failedRuns) while keeping the visual UI
unchanged; locate the Activity components and the completedRuns/failedRuns
metric render functions in ai-analysis.vue and apply these attribute changes
consistently.
In `@app/pages/dashboard/jobs/`[id]/index.vue:
- Around line 353-377: The timeline load can commit stale data when the
candidate changes because loadTimeline reads resolvedCurrentApplication lazily
and sets timelineItems/timelineLoaded unconditionally; fix by capturing the
candidate id at the start of loadTimeline (use the local candId variable) and
ignore results that don't match that captured id before assigning
timelineItems/timelineLoaded, or alternatively store timeline state keyed by
candidate id (e.g., timelineByCandidate[candId] with its own loaded flag).
Update loadTimeline, timelineLoaded, timelineItems, and the watch on
timelineCandidateId/detailTab to use the per-candidate guard (or request token)
so only responses matching the originally requested candidate update the UI.
In `@app/pages/dashboard/jobs/new.vue`:
- Around line 374-388: Both createChannelLink and createCustomBoardLink are
vulnerable to double-submission because they only guard after a code exists (or
set the creating flag too late); update createChannelLink to check
createdLinks.value[channel]?.code OR createdLinks.value[channel]?.loading at the
very top and set createdLinks.value[channel] = { code: '', url: '', loading:
true, copied: false } immediately before any await so subsequent calls see
loading=true; do the same for createCustomBoardLink by checking
isCreatingCustomBoard at the start, setting isCreatingCustomBoard = true
immediately before the POST, and clear the flags in a finally block (reset
loading or isCreatingCustomBoard on error) so duplicate requests cannot be
queued.
In `@server/api/tracking-links/`[id]/stats.get.ts:
- Around line 30-127: The analytics queries are only scoped by trackingLinkId
and can leak other tenants' data; add org scoping: include
eq(job.organizationId, orgId) in the job lookup inside job.findFirst, and ensure
every analytics query uses the tenant predicate(s) by adding
eq(application.organizationId, orgId) and eq(job.organizationId, orgId) (and
eq(candidate.organizationId, orgId) for the attributedApplications join) into
the shared dateConditions/whereClause (or combine with and(...) in each query)
so statusBreakdown, dailyTrend, attributedApplications, referrerDomains and
totalAttributed are all filtered by the current orgId.
---
Duplicate comments:
In `@app/pages/dashboard/source-tracking/index.vue`:
- Around line 63-72: The Active Links KPI and the "Create Your First Tracking
Link" empty-state are rendering counts/empty when links are still loading;
update the UI to wait for linksStatus (from useTrackingLinks) to reflect a
settled fetch before using links or totalLinks—e.g., only show the numeric KPI
or the "no links" branch when linksStatus === 'success' (or not 'loading'),
otherwise render a loading/skeleton state or nothing; apply this guard to all
places reading links/totalLinks (the KPI component and the create-first-link
branch, plus the other occurrences referenced) so unresolved or errored fetches
don't render as zero/empty.
- Around line 75-79: The query fetching jobs uses useFetch('/api/jobs', { key:
'source-tracking-jobs', headers: useRequestHeaders(['cookie']), query: { limit:
100 } }) which artificially caps results at 100; remove the fixed query.limit or
replace it with a pagination/loading strategy so the page-level job filter and
the "Scope to Job" selector see all jobs (e.g., remove query.limit or implement
incremental loading with page/offset params and a "load more" or fetch-all
option in the component that uses the 'source-tracking-jobs' data).
- Around line 820-901: The action buttons container currently uses "opacity-0
group-hover:opacity-100" which hides controls for touch and keyboard users;
update the div with class "inline-flex items-center gap-1 opacity-0
group-hover:opacity-100 transition-opacity" to make actions visible by default
on small screens and also reveal on group-focus-within: replace its classes with
something like "inline-flex items-center gap-1 opacity-100 sm:opacity-0
group-hover:opacity-100 group-focus-within:opacity-100 transition-opacity" so
mobile users see buttons and keyboard users see them when the row receives
focus; verify this change for the div that contains the Copy/Toggle/Delete
buttons (associated with copiedCode, copyTrackingUrl, toggleLink, confirmDelete
and canManageLinks).
- Around line 1019-1123: The create (Teleport using showCreateModal, form with
handleCreateLink, title h2 and input id="link-name") and delete dialogs must be
made accessible: add role="dialog" and aria-modal="true" on the dialog container
and give the title an id (e.g., id on the h2) and set aria-labelledby to that
id; when showCreateModal/showDeleteModal opens, move initial focus into the
dialog (e.g., focus the first interactive element like input#link-name or the
primary confirm button) and trap focus inside until closed; add an Escape key
handler to close the modal (set showCreateModal=false / showDeleteModal=false)
and restore focus to the element that opened the dialog; also ensure the
background content is hidden from assistive tech (e.g., set aria-hidden on the
main app container or use inert) while the modal is open.
---
Nitpick comments:
In `@app/pages/dashboard/ai-analysis.vue`:
- Around line 189-283: The dashboard has five near-identical stat card blocks;
extract a reusable StatCard component and replace the repeated markup in
ai-analysis.vue with it. Create a StatCard that accepts props like title (e.g.,
"Total Runs"), value (or a value slot), icon component, accent classes (for
gradient/hover color), small subtitle/description, and an optional footer slot
for conditional content (used for the pricing block); update usages to pass
existing expressions/refs (formatNumber(summary.totalRuns), successRate,
formatCost(totalCost), summary.completedRuns, summary.failedRuns,
pricing.configured, etc.) and preserve current class/transition behavior by
moving conditional :class logic into props. Ensure the new component exposes the
same DOM structure so utilities like group-hover and absolute icons continue to
work.
In `@app/pages/dashboard/jobs/new.vue`:
- Around line 1553-1746: The template duplicates the same card markup for each
category; refactor by grouping distributionChannels by category and rendering a
single reusable component (e.g., ChannelGroup) or template that accepts props:
channels (array), createdLinks, createChannelLink, copyChannelLink,
channelIcons, and defaultIcon (Globe). Replace the three v-for blocks that
filter by c.category === 'job_board'/'outreach'/'social' with a single loop over
Object.entries(groupedChannels) (or an array of categories) that renders
<ChannelGroup :channels="channels" :created-links="createdLinks"
:channel-icons="channelIcons" `@create`="createChannelLink"
`@copy`="copyChannelLink" />, and move the duplicated UI (input, buttons, loading
states, classes and checks for createdLinks[ch.channel]?.code/copied/loading)
into that component/template so behavior remains identical while removing
repetition.
In `@tests/unit/candidate-timeline.test.ts`:
- Around line 8-128: The test recreates core logic locally (querySchema,
getTimelineActionColor, describeTimelineItem, makeEntry) instead of importing
the canonical implementations, so extract the shared schema/helpers into a
single exportable module (or import the existing implementations from
server/api/activity-log/candidate-timeline.get.ts and
app/components/CandidateDetailSidebar.vue) and update the test to import
querySchema, getTimelineActionColor, describeTimelineItem and makeEntry from
that module; alternatively, replace the unit-level recreation by mounting the
real component/composable (e.g., CandidateDetailSidebar or the
candidate-timeline composable) in the test and exercising its watchers/endpoint
calls so the spec drives the actual code paths rather than cloned logic.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 58f1e660-7305-4492-936b-13287242ec72
⛔ Files ignored due to path filters (1)
package-lock.jsonis excluded by!**/package-lock.json
📒 Files selected for processing (12)
.gitignoreapp/components/CandidateDetailSidebar.vueapp/composables/useSourceTracking.tsapp/pages/dashboard/ai-analysis.vueapp/pages/dashboard/index.vueapp/pages/dashboard/jobs/[id]/index.vueapp/pages/dashboard/jobs/new.vueapp/pages/dashboard/source-tracking/[id].vueapp/pages/dashboard/source-tracking/index.vueserver/api/activity-log/candidate-timeline.get.tsserver/api/tracking-links/[id]/stats.get.tstests/unit/candidate-timeline.test.ts
✅ Files skipped from review due to trivial changes (2)
- .gitignore
- app/composables/useSourceTracking.ts
🚧 Files skipped from review as they are similar to previous changes (1)
- app/pages/dashboard/index.vue
| async function loadTimeline() { | ||
| if (!candidateId.value) return | ||
| timelineLoading.value = true | ||
| timelineError.value = null | ||
| try { | ||
| const result = await $fetch<{ items: TimelineEntry[] }>('/api/activity-log/candidate-timeline', { | ||
| query: { candidateId: candidateId.value }, | ||
| }) | ||
| timelineItems.value = result.items | ||
| timelineLoaded.value = true | ||
| } catch (err: any) { | ||
| timelineError.value = err?.data?.statusMessage ?? 'Failed to load timeline' | ||
| } finally { | ||
| timelineLoading.value = false | ||
| } | ||
| } | ||
|
|
||
| // Load timeline data lazily when tab is selected | ||
| watch(activeTab, (tab) => { | ||
| if (tab === 'timeline' && !timelineLoaded.value && candidateId.value) { | ||
| loadTimeline() | ||
| } | ||
| }) |
There was a problem hiding this comment.
Reload the timeline for the active candidate, not just for the active tab.
After props.applicationId changes, application.value can still hold the previous application until useFetch resolves. Because this logic only watches activeTab and unconditionally stores the fetch result, reopening Timeline during that window can show the previous candidate's activity and suppress the follow-up fetch for the new one. Key the load/loaded state by the resolved candidateId, and ignore responses whose candidate no longer matches the active application.
Also applies to: 385-395
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@app/components/CandidateDetailSidebar.vue` around lines 361 - 383, The
timeline loader currently only watches activeTab and can accept stale fetch
results when props.applicationId/candidateId changes; update loadTimeline (and
the watcher) to key load/loaded state by the resolved candidateId: at start of
loadTimeline capture const resolvedCandidateId = candidateId.value, bail if
falsy, set a per-request flag (or store resolvedCandidateId in
timelineLoadedForCandidate), then after the fetch verify candidateId.value ===
resolvedCandidateId before assigning timelineItems.value, timelineLoaded.value
and clearing timelineError; also update the watch to trigger when
candidateId/applicationId changes (or include candidateId in the watch) so
reopening the timeline for a new candidate forces a new load and ignores stale
responses.
| <div class="absolute inset-0 bg-gradient-to-br from-brand-50/50 to-transparent dark:from-brand-950/20 dark:to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-200" /> | ||
| <div class="group relative rounded-2xl bg-white dark:bg-surface-900 p-5 sm:p-6 overflow-hidden isolate ring-1 ring-surface-950/[0.04] dark:ring-white/[0.06] hover:ring-brand-500/25 dark:hover:ring-brand-400/25 transition-all duration-300 hover:-translate-y-0.5 hover:shadow-lg hover:shadow-brand-500/[0.08]"> | ||
| <div class="absolute inset-x-0 top-0 h-px bg-gradient-to-r from-transparent via-brand-500 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-500" /> | ||
| <Activity class="absolute -bottom-3 -right-3 size-24 text-brand-500/[0.03] dark:text-brand-400/[0.05] rotate-12 transition-transform duration-700 ease-out group-hover:rotate-3 group-hover:scale-110 pointer-events-none" /> |
There was a problem hiding this comment.
Add accessible labels for icon-only metrics and hide decorative icons from AT.
At Line 200-Line 208, completedRuns/failedRuns are now icon + number only, which is ambiguous for screen readers. Also, large decorative icons at Line 191, Line 216, Line 234, Line 250, and Line 266 should be aria-hidden.
♿ Suggested accessibility patch
- <Activity class="absolute -bottom-3 -right-3 size-24 text-brand-500/[0.03] dark:text-brand-400/[0.05] rotate-12 transition-transform duration-700 ease-out group-hover:rotate-3 group-hover:scale-110 pointer-events-none" />
+ <Activity aria-hidden="true" class="absolute -bottom-3 -right-3 size-24 text-brand-500/[0.03] dark:text-brand-400/[0.05] rotate-12 transition-transform duration-700 ease-out group-hover:rotate-3 group-hover:scale-110 pointer-events-none" />
<div class="mt-1 flex items-center gap-3 text-[11px]">
<span class="flex items-center gap-1 text-success-600 dark:text-success-400">
- <CheckCircle2 class="size-3" />
+ <CheckCircle2 aria-hidden="true" class="size-3" />
+ <span class="sr-only">Completed runs:</span>
{{ summary.completedRuns }}
</span>
<span v-if="summary.failedRuns > 0" class="flex items-center gap-1 text-danger-600 dark:text-danger-400">
- <XCircle class="size-3" />
+ <XCircle aria-hidden="true" class="size-3" />
+ <span class="sr-only">Failed runs:</span>
{{ summary.failedRuns }}
</span>
</div>
- <TrendingUp class="absolute -bottom-3 -right-3 size-24 text-success-500/[0.03] dark:text-success-400/[0.05] rotate-12 transition-transform duration-700 ease-out group-hover:rotate-3 group-hover:scale-110 pointer-events-none" />
+ <TrendingUp aria-hidden="true" class="absolute -bottom-3 -right-3 size-24 text-success-500/[0.03] dark:text-success-400/[0.05] rotate-12 transition-transform duration-700 ease-out group-hover:rotate-3 group-hover:scale-110 pointer-events-none" />
- <Zap class="absolute -bottom-3 -right-3 size-24 text-violet-500/[0.03] dark:text-violet-400/[0.05] rotate-12 transition-transform duration-700 ease-out group-hover:rotate-3 group-hover:scale-110 pointer-events-none" />
+ <Zap aria-hidden="true" class="absolute -bottom-3 -right-3 size-24 text-violet-500/[0.03] dark:text-violet-400/[0.05] rotate-12 transition-transform duration-700 ease-out group-hover:rotate-3 group-hover:scale-110 pointer-events-none" />
- <Sparkles class="absolute -bottom-3 -right-3 size-24 text-amber-500/[0.03] dark:text-amber-400/[0.05] rotate-12 transition-transform duration-700 ease-out group-hover:rotate-3 group-hover:scale-110 pointer-events-none" />
+ <Sparkles aria-hidden="true" class="absolute -bottom-3 -right-3 size-24 text-amber-500/[0.03] dark:text-amber-400/[0.05] rotate-12 transition-transform duration-700 ease-out group-hover:rotate-3 group-hover:scale-110 pointer-events-none" />
- <DollarSign class="absolute -bottom-3 -right-3 size-24 text-emerald-500/[0.03] dark:text-emerald-400/[0.05] rotate-12 transition-transform duration-700 ease-out group-hover:rotate-3 group-hover:scale-110 pointer-events-none" />
+ <DollarSign aria-hidden="true" class="absolute -bottom-3 -right-3 size-24 text-emerald-500/[0.03] dark:text-emerald-400/[0.05] rotate-12 transition-transform duration-700 ease-out group-hover:rotate-3 group-hover:scale-110 pointer-events-none" />Also applies to: 200-208, 216-216, 234-234, 250-250, 266-266
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@app/pages/dashboard/ai-analysis.vue` at line 191, The large decorative
Activity icon component and other decorative icons (e.g., the Activity instances
at the positions noted) should be hidden from assistive tech by adding
aria-hidden="true" to those icon elements; additionally, for icon-only metric
displays such as completedRuns and failedRuns, add an accessible label (e.g.,
aria-label or visually-hidden text) to the metric container or the icon so
screen readers see a clear description (for example, aria-label="Completed runs:
X" on the element rendering completedRuns and similarly for failedRuns) while
keeping the visual UI unchanged; locate the Activity components and the
completedRuns/failedRuns metric render functions in ai-analysis.vue and apply
these attribute changes consistently.
| async function createChannelLink(channel: string, channelName: string) { | ||
| if (createdLinks.value[channel]?.code) return | ||
| createdLinks.value[channel] = { code: '', url: '', loading: true, copied: false } | ||
| try { | ||
| const result = await $fetch<{ id: string; code: string }>('/api/tracking-links', { | ||
| method: 'POST', | ||
| body: { | ||
| jobId: createdJobId.value, | ||
| channel, | ||
| name: `${form.value.title} — ${channelName}`, | ||
| }, | ||
| }) | ||
| const base = `${requestUrl.protocol}//${requestUrl.host}` | ||
| const trackUrl = `${base}/api/public/track/${encodeURIComponent(result.code)}` | ||
| createdLinks.value[channel] = { code: result.code, url: trackUrl, loading: false, copied: false } |
There was a problem hiding this comment.
Guard both tracking-link creates against double submission.
A rapid double-click or Enter can queue two /api/tracking-links POSTs here because createChannelLink() only blocks once a code exists, and createCustomBoardLink() doesn't check isCreatingCustomBoard before the request starts. That can leave duplicate links for the same surface and split attribution data.
🛡️ Minimal guard to prevent duplicate creates
async function createChannelLink(channel: string, channelName: string) {
- if (createdLinks.value[channel]?.code) return
+ if (createdLinks.value[channel]?.loading || createdLinks.value[channel]?.code) return
createdLinks.value[channel] = { code: '', url: '', loading: true, copied: false }
try { async function createCustomBoardLink() {
+ if (isCreatingCustomBoard.value) return
const name = customBoardName.value.trim()
if (!name) return
+ isCreatingCustomBoard.value = true
// Use a slug derived from the custom board name for local dedup only
const dedupeKey = `custom_${name.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_|_$/g, '').slice(0, 50)}`
// Prevent duplicates
if (customBoardLinks.value.some(l => l.channel === dedupeKey)) {
toast.warning('Duplicate board', `A custom link for "${name}" already exists.`)
+ isCreatingCustomBoard.value = false
return
}
-
- isCreatingCustomBoard.value = trueAlso applies to: 417-447
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@app/pages/dashboard/jobs/new.vue` around lines 374 - 388, Both
createChannelLink and createCustomBoardLink are vulnerable to double-submission
because they only guard after a code exists (or set the creating flag too late);
update createChannelLink to check createdLinks.value[channel]?.code OR
createdLinks.value[channel]?.loading at the very top and set
createdLinks.value[channel] = { code: '', url: '', loading: true, copied: false
} immediately before any await so subsequent calls see loading=true; do the same
for createCustomBoardLink by checking isCreatingCustomBoard at the start,
setting isCreatingCustomBoard = true immediately before the POST, and clear the
flags in a finally block (reset loading or isCreatingCustomBoard on error) so
duplicate requests cannot be queued.
| // ─── Scoped job title ───────────────────── | ||
| let jobTitle: string | null = null | ||
| if (link.jobId) { | ||
| const j = await db.query.job.findFirst({ | ||
| where: eq(job.id, link.jobId), | ||
| columns: { title: true }, | ||
| }) | ||
| jobTitle = j?.title ?? null | ||
| } | ||
|
|
||
| // ─── Date conditions ────────────────────── | ||
| const dateConditions = [eq(applicationSource.trackingLinkId, id)] | ||
| if (query.from) { | ||
| dateConditions.push(gte(applicationSource.createdAt, new Date(query.from))) | ||
| } | ||
| if (query.to) { | ||
| dateConditions.push(lte(applicationSource.createdAt, new Date(query.to))) | ||
| } | ||
|
|
||
| const whereClause = and(...dateConditions) | ||
|
|
||
| // ─── Run all analytics queries in parallel ─ | ||
| const [ | ||
| statusBreakdown, | ||
| dailyTrend, | ||
| attributedApplications, | ||
| referrerDomains, | ||
| totalAttributed, | ||
| ] = await Promise.all([ | ||
| // 1. Application status breakdown | ||
| db | ||
| .select({ | ||
| status: application.status, | ||
| count: count().as('count'), | ||
| }) | ||
| .from(applicationSource) | ||
| .innerJoin(application, eq(application.id, applicationSource.applicationId)) | ||
| .where(whereClause) | ||
| .groupBy(application.status), | ||
|
|
||
| // 2. Daily trend (applications over time) | ||
| db | ||
| .select({ | ||
| date: sql<string>`date_trunc('day', ${applicationSource.createdAt})::date`.as('day'), | ||
| count: count().as('count'), | ||
| }) | ||
| .from(applicationSource) | ||
| .innerJoin(application, eq(application.id, applicationSource.applicationId)) | ||
| .where(whereClause) | ||
| .groupBy(sql`date_trunc('day', ${applicationSource.createdAt})::date`) | ||
| .orderBy(sql`date_trunc('day', ${applicationSource.createdAt})::date`), | ||
|
|
||
| // 3. All attributed applications with candidate + job info | ||
| db | ||
| .select({ | ||
| applicationId: applicationSource.applicationId, | ||
| channel: applicationSource.channel, | ||
| utmSource: applicationSource.utmSource, | ||
| utmMedium: applicationSource.utmMedium, | ||
| utmCampaign: applicationSource.utmCampaign, | ||
| utmTerm: applicationSource.utmTerm, | ||
| utmContent: applicationSource.utmContent, | ||
| referrerDomain: applicationSource.referrerDomain, | ||
| candidateFirstName: candidate.firstName, | ||
| candidateLastName: candidate.lastName, | ||
| candidateEmail: candidate.email, | ||
| jobTitle: job.title, | ||
| jobId: application.jobId, | ||
| status: application.status, | ||
| appliedAt: applicationSource.createdAt, | ||
| }) | ||
| .from(applicationSource) | ||
| .innerJoin(application, eq(application.id, applicationSource.applicationId)) | ||
| .innerJoin(candidate, eq(candidate.id, application.candidateId)) | ||
| .innerJoin(job, eq(job.id, application.jobId)) | ||
| .where(whereClause) | ||
| .orderBy(desc(applicationSource.createdAt)) | ||
| .limit(100), | ||
|
|
||
| // 4. Referrer domain breakdown | ||
| db | ||
| .select({ | ||
| domain: applicationSource.referrerDomain, | ||
| count: count().as('count'), | ||
| }) | ||
| .from(applicationSource) | ||
| .innerJoin(application, eq(application.id, applicationSource.applicationId)) | ||
| .where(and( | ||
| ...dateConditions, | ||
| sql`${applicationSource.referrerDomain} IS NOT NULL`, | ||
| )) | ||
| .groupBy(applicationSource.referrerDomain) | ||
| .orderBy(sql`count(*) desc`) | ||
| .limit(10), | ||
|
|
||
| // 5. Total attributed count | ||
| db.$count(applicationSource, eq(applicationSource.trackingLinkId, id)), | ||
| ]) |
There was a problem hiding this comment.
Enforce orgId on the joined analytics queries.
After the initial trackingLink lookup, the rest of the handler is effectively scoped only by trackingLinkId. If an applicationSource row ever points at an application/job/candidate from another tenant, this endpoint will return that tenant's email and job metadata. Add organizationId = orgId predicates to the job lookup and the joined analytics queries so the tenant boundary is enforced here instead of relying on cross-table consistency.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@server/api/tracking-links/`[id]/stats.get.ts around lines 30 - 127, The
analytics queries are only scoped by trackingLinkId and can leak other tenants'
data; add org scoping: include eq(job.organizationId, orgId) in the job lookup
inside job.findFirst, and ensure every analytics query uses the tenant
predicate(s) by adding eq(application.organizationId, orgId) and
eq(job.organizationId, orgId) (and eq(candidate.organizationId, orgId) for the
attributedApplications join) into the shared dateConditions/whereClause (or
combine with and(...) in each query) so statusBreakdown, dailyTrend,
attributedApplications, referrerDomains and totalAttributed are all filtered by
the current orgId.
| const dateConditions = [eq(applicationSource.trackingLinkId, id)] | ||
| if (query.from) { | ||
| dateConditions.push(gte(applicationSource.createdAt, new Date(query.from))) | ||
| } | ||
| if (query.to) { | ||
| dateConditions.push(lte(applicationSource.createdAt, new Date(query.to))) | ||
| } | ||
|
|
||
| const whereClause = and(...dateConditions) |
There was a problem hiding this comment.
The date-filtered response currently mixes filtered and all-time KPIs.
statusBreakdown, dailyTrend, attributedApplications, and referrerDomains honor from/to, but totalAttributed, link.applicationCount, link.clickCount, and the derived cvr are still all-time. With a 7d/30d filter selected, the dashboard can therefore show contradictory numbers for the same view. Either return period-scoped KPI fields here as well, or make these fields explicitly allTime* so the UI can separate the timeframes.
Also applies to: 125-163
There was a problem hiding this comment.
Actionable comments posted: 1
♻️ Duplicate comments (1)
app/pages/dashboard/jobs/[id]/index.vue (1)
455-490:⚠️ Potential issue | 🟠 MajorKeep timeline state keyed to the requested candidate.
This still has the stale-response bug from the earlier review:
resolvedCurrentApplicationcan point at the previous candidate while the next detail request is in flight, soloadTimeline()can fetch and commit the wrong history. After that,timelineLoadedflips totrueand prevents the actual candidate from loading. BasetimelineCandidateIdon the currently loaded application only, and ignore any response whose candidate/request id no longer matches before assigningtimelineItems.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@app/pages/dashboard/jobs/`[id]/index.vue around lines 455 - 490, The timeline fetch can commit stale results because resolvedCurrentApplication may change while a request is in flight; update loadTimeline and timelineCandidateId to key state to the requested candidate by capturing the candidate id at request start (e.g., const requestedId = resolvedCurrentApplication.value?.candidate?.id) and returning early if missing, then after the $fetch completes verify that resolvedCurrentApplication.value?.candidate?.id === requestedId before assigning timelineItems, timelineLoaded, timelineError or timelineLoading; also ensure timelineCandidateId is computed from the currently loaded application state only and that any watcher (watch([detailTab, timelineCandidateId], ...)) uses that keyed id to decide to call loadTimeline so stale responses are ignored.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@app/pages/dashboard/jobs/`[id]/index.vue:
- Around line 2499-2500: The template currently uses a truthy check (item.action
=== 'scored' && item.metadata?.score) which hides valid 0 scores; change the
condition to explicitly check for null/undefined (for example: item.action ===
'scored' && item.metadata?.score != null) or use a numeric check (e.g., typeof
item.metadata?.score === 'number' || Number.isFinite(item.metadata?.score')) so
the badge renders when score is 0; update the v-else-if in the template that
references item.action and item.metadata?.score accordingly.
---
Duplicate comments:
In `@app/pages/dashboard/jobs/`[id]/index.vue:
- Around line 455-490: The timeline fetch can commit stale results because
resolvedCurrentApplication may change while a request is in flight; update
loadTimeline and timelineCandidateId to key state to the requested candidate by
capturing the candidate id at request start (e.g., const requestedId =
resolvedCurrentApplication.value?.candidate?.id) and returning early if missing,
then after the $fetch completes verify that
resolvedCurrentApplication.value?.candidate?.id === requestedId before assigning
timelineItems, timelineLoaded, timelineError or timelineLoading; also ensure
timelineCandidateId is computed from the currently loaded application state only
and that any watcher (watch([detailTab, timelineCandidateId], ...)) uses that
keyed id to decide to call loadTimeline so stale responses are ignored.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 03a94af9-58ac-498b-90ca-f83fa58f99a2
📒 Files selected for processing (1)
app/pages/dashboard/jobs/[id]/index.vue
| <template v-else-if="item.action === 'scored' && item.metadata?.score"> | ||
| <span class="inline-flex items-center rounded px-1.5 py-0.5 text-[10px] font-medium leading-none bg-accent-100 text-accent-700 dark:bg-accent-900/60 dark:text-accent-300">{{ item.metadata.score }} pts</span> |
There was a problem hiding this comment.
Don't hide valid 0 scores in the timeline.
This truthy check drops the badge for score = 0, even though 0 is a valid numeric score elsewhere on this page. Use a null check instead.
Suggested fix
- <template v-else-if="item.action === 'scored' && item.metadata?.score">
+ <template v-else-if="item.action === 'scored' && item.metadata && item.metadata.score != null">
<span class="inline-flex items-center rounded px-1.5 py-0.5 text-[10px] font-medium leading-none bg-accent-100 text-accent-700 dark:bg-accent-900/60 dark:text-accent-300">{{ item.metadata.score }} pts</span>
</template>🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@app/pages/dashboard/jobs/`[id]/index.vue around lines 2499 - 2500, The
template currently uses a truthy check (item.action === 'scored' &&
item.metadata?.score) which hides valid 0 scores; change the condition to
explicitly check for null/undefined (for example: item.action === 'scored' &&
item.metadata?.score != null) or use a numeric check (e.g., typeof
item.metadata?.score === 'number' || Number.isFinite(item.metadata?.score')) so
the badge renders when score is 0; update the v-else-if in the template that
references item.action and item.metadata?.score accordingly.
- Updated `useSourceTracking` to include jobId in source stats. - Implemented tracking links creation, deletion, and toggling in the application form. - Enhanced the dashboard to display tracking links with their respective channels and statuses. - Added source attribution records for applications in the seed script. - Increased the limit for tracked applications in the stats API.
… URL generation and sorting functionality
- In `renew-webhooks.post.ts`, implemented fixed-length buffers for comparing CRON secrets to prevent timing attacks. - Updated error handling to throw a 403 status for invalid cron secrets. - In `track/[code].get.ts`, added a check for the `BETTER_AUTH_URL` environment variable and throw a 500 error if misconfigured. - Ensured `ref` parameter in redirect URLs is properly encoded to prevent potential issues with special characters.
…g code generation and enhance validation for tracking codes
createTrackingLinkSchemaandupdateTrackingLinkSchemafor validating tracking link data.trackingLinkIdSchemafor route parameter validation.trackingLinkQuerySchemafor listing tracking links with pagination and filtering options.sourceStatsQuerySchemafor querying source tracking statistics.applicationSourceSchemafor capturing source attribution from URL query parameters.Summary
Type of change
Validation
DCO
Signed-off-by) viagit commit -sSummary by CodeRabbit
New Features
Bug Fixes
Tests
Chores